Compare commits

..
188 changed files with 2051 additions and 5969 deletions

View file

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

View file

@ -1,97 +1,37 @@
# Contributing # Contributing
Thanks for helping with OTKO — a SAP2000-style desktop GUI for Early-stage project. The bar is architecture cleanliness, not feature
OpenSeesPy. Early-stage project: the bar is architecture cleanliness, not count. If your change breaks a layering rule below, it won't merge —
feature count. If your change breaks a layering rule below, it won't no matter how useful the feature.
merge — no matter how useful the feature.
## Dev setup ## Dev setup
```bash ```bash
python -m venv .venv python -m venv .venv
source .venv/bin/activate # Linux / macOS source .venv/bin/activate
# .venv\Scripts\activate # Windows pip install -e ".[dev]"
pip install -e ".[gui,dev]"
pre-commit install 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 ## Before opening a PR
Run the verify commands in this order and make sure they are all clean:
```bash ```bash
ruff check src tests ruff check src tests
ruff format src tests # line-length 100, E501 ignored ruff format src tests
mypy src/otko/core src/otko/services 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) ## Architectural rules (enforced in review)
OTKO uses a strict one-way **MVVM + service** architecture. Dependencies 1. `core/` may not import Qt or `openseespy`. Period.
flow outward-in only: 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.
views → viewmodels → services → core 5. New domain entities go through Pydantic validation.
``` 6. Long-running operations (>50 ms) run off the GUI thread.
`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.
## Commit style ## Commit style
Conventional Commits — `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, Conventional Commits — `feat:`, `fix:`, `refactor:`, `docs:`, `test:`,
`chore:`, `ci:`. Keep each commit focused; a `feat:` commit should add a `chore:`, `ci:`.
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.

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), Long version in [`docs/architecture.md`](docs/architecture.md),
including the OpenSeesPy command order the runner emits. 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) ## Install (development)
**Desktop GUI** (Qt, PyVista, pyqtgraph, imageio): **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 "SIM", # simplify
"RUF", # ruff-specific "RUF", # ruff-specific
] ]
ignore = [ ignore = ["E501"] # line length handled by formatter
"E501", # line-length handled by the formatter
"RUF001", # ambiguous unicode: engineering notation (×, ≈, ²) is deliberate
"RUF002",
"RUF003",
]
[tool.ruff.lint.per-file-ignores]
# Engineering symbols (A, I, L, E, P) and `importorskip`-then-import blocks
# are normal in tests.
"tests/**" = ["E402", "E741", "N802", "N806", "N815"]
# Qt requires camelCase signal names and PascalCase event overrides.
"src/otko/views/**" = ["N802", "N815"]
"src/otko/viewmodels/**" = ["N815"]
# Material fields mirror the OpenSees parameter names verbatim (cR1, epsU, ...).
"src/otko/core/materials/__init__.py" = ["N815"]
# Engineering symbols in numeric code: L (length), Iz/Iy (second moments).
"src/otko/services/section_properties.py" = ["N806"]
"src/otko/views/canvas3d/model_renderer.py" = ["N802", "N806", "N815"]
[tool.ruff.format] [tool.ruff.format]
quote-style = "double" 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, UpdateAnalysisCaseCommand,
) )
from otko.commands.base import ProjectCommand from otko.commands.base import ProjectCommand
from otko.commands.combinations import (
AddCombinationsCommand,
DeleteCombinationsCommand,
UpdateCombinationCommand,
)
from otko.commands.constraints import ( from otko.commands.constraints import (
AddEqualDOFConstraintCommand, AddEqualDOFConstraintCommand,
DeleteEqualDOFConstraintCommand, DeleteEqualDOFConstraintCommand,
@ -29,6 +24,14 @@ from otko.commands.grid import (
SetCoordSystemsCommand, SetCoordSystemsCommand,
SetGridSystemCommand, SetGridSystemCommand,
) )
from otko.commands.series_and_patterns import (
AddLoadPatternCommand,
AddTimeSeriesCommand,
DeleteLoadPatternCommand,
DeleteTimeSeriesCommand,
UpdateLoadPatternCommand,
UpdateTimeSeriesCommand,
)
from otko.commands.loads import ( from otko.commands.loads import (
AddElementLoadsCommand, AddElementLoadsCommand,
AddNodalLoadsCommand, AddNodalLoadsCommand,
@ -55,14 +58,6 @@ from otko.commands.sections import (
DeleteSectionsCommand, DeleteSectionsCommand,
UpdateSectionCommand, UpdateSectionCommand,
) )
from otko.commands.series_and_patterns import (
AddLoadPatternCommand,
AddTimeSeriesCommand,
DeleteLoadPatternCommand,
DeleteTimeSeriesCommand,
UpdateLoadPatternCommand,
UpdateTimeSeriesCommand,
)
from otko.commands.transforms import ( from otko.commands.transforms import (
MirrorCommand, MirrorCommand,
MoveNodesCommand, MoveNodesCommand,
@ -72,50 +67,25 @@ from otko.commands.transforms import (
__all__ = [ __all__ = [
"ProjectCommand", "ProjectCommand",
"AddNodesCommand", "AddNodesCommand", "DeleteNodesCommand", "SetRestraintCommand", "SetMassCommand",
"DeleteNodesCommand",
"SetRestraintCommand",
"SetMassCommand",
"UpdateNodeFieldsCommand", "UpdateNodeFieldsCommand",
"AddElementsCommand", "AddElementsCommand", "DeleteElementsCommand",
"DeleteElementsCommand", "AssignSectionCommand", "AssignMaterialCommand",
"AssignSectionCommand", "ReplaceElementsCommand", "ConvertElementTypeCommand",
"AssignMaterialCommand",
"ReplaceElementsCommand",
"ConvertElementTypeCommand",
"UpdateElementFieldsCommand", "UpdateElementFieldsCommand",
"AddMaterialsCommand", "AddMaterialsCommand", "DeleteMaterialsCommand", "UpdateMaterialCommand",
"DeleteMaterialsCommand", "AddSectionsCommand", "DeleteSectionsCommand", "UpdateSectionCommand",
"UpdateMaterialCommand", "AddNodalLoadsCommand", "AddElementLoadsCommand",
"AddSectionsCommand", "UpdateNodalLoadCommand", "DeleteNodalLoadsCommand",
"DeleteSectionsCommand", "UpdateElementLoadCommand", "DeleteElementLoadsCommand",
"UpdateSectionCommand", "MoveNodesCommand", "ReplicateCommand", "MirrorCommand", "Plane",
"AddNodalLoadsCommand", "AddAnalysisCasesCommand", "DeleteAnalysisCasesCommand", "UpdateAnalysisCaseCommand",
"AddElementLoadsCommand",
"UpdateNodalLoadCommand",
"DeleteNodalLoadsCommand",
"UpdateElementLoadCommand",
"DeleteElementLoadsCommand",
"MoveNodesCommand",
"ReplicateCommand",
"MirrorCommand",
"Plane",
"AddAnalysisCasesCommand",
"DeleteAnalysisCasesCommand",
"UpdateAnalysisCaseCommand",
"AddCombinationsCommand",
"DeleteCombinationsCommand",
"UpdateCombinationCommand",
"SetGridSystemCommand", "SetGridSystemCommand",
"SetCoordSystemsCommand", "SetCoordSystemsCommand",
"AddTimeSeriesCommand", "AddTimeSeriesCommand", "AddLoadPatternCommand",
"AddLoadPatternCommand", "UpdateTimeSeriesCommand", "DeleteTimeSeriesCommand",
"UpdateTimeSeriesCommand", "UpdateLoadPatternCommand", "DeleteLoadPatternCommand",
"DeleteTimeSeriesCommand",
"UpdateLoadPatternCommand",
"DeleteLoadPatternCommand",
"AddEqualDOFConstraintCommand", "AddEqualDOFConstraintCommand",
"UpdateEqualDOFConstraintCommand", "UpdateEqualDOFConstraintCommand", "DeleteEqualDOFConstraintCommand",
"DeleteEqualDOFConstraintCommand",
"SetUnitsCommand", "SetUnitsCommand",
] ]

View file

@ -13,7 +13,8 @@ if TYPE_CHECKING:
class AddAnalysisCasesCommand(ProjectCommand): class AddAnalysisCasesCommand(ProjectCommand):
"""Add one or more analysis cases.""" """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)") super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
self._cases = list(cases) self._cases = list(cases)
@ -34,16 +35,19 @@ class AddAnalysisCasesCommand(ProjectCommand):
class DeleteAnalysisCasesCommand(ProjectCommand): class DeleteAnalysisCasesCommand(ProjectCommand):
"""Remove a set of analysis cases.""" """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)") super().__init__(vm, f"Delete {len(case_ids)} analysis case(s)")
self._case_ids = set(case_ids) self._case_ids = set(case_ids)
self._removed: list[tuple[int, Any]] = [] self._removed: list[tuple[int, Any]] = []
def redo(self) -> None: def redo(self) -> None:
self._removed = [ 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() self._notify()
def undo(self) -> None: def undo(self) -> None:
@ -56,7 +60,7 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
class UpdateAnalysisCaseCommand(ProjectCommand): class UpdateAnalysisCaseCommand(ProjectCommand):
"""Replace an analysis case at a given id.""" """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}") super().__init__(vm, f"Edit analysis case {new_case.id}")
self._new = new_case self._new = new_case
self._old: Any | None = None self._old: Any | None = None

View file

@ -27,18 +27,20 @@ class ProjectCommand(QUndoCommand):
:meth:`_notify` exactly once at the end of each. :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) super().__init__(text)
self._vm = vm self._vm = vm
@property @property
def vm(self) -> ProjectViewModel: def vm(self) -> "ProjectViewModel":
return self._vm return self._vm
@property @property
def project(self) -> Project: def project(self) -> "Project":
if self._vm.project is None: 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 return self._vm.project
def _notify(self) -> None: 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): class AddEqualDOFConstraintCommand(ProjectCommand):
"""Append an ``equalDOF`` constraint to the project (undoable).""" """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__( super().__init__(
vm, vm,
f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}", f"Add equalDOF {constraint.retained_node}->{constraint.constrained_node}",
@ -34,7 +34,8 @@ class AddEqualDOFConstraintCommand(ProjectCommand):
def undo(self) -> None: def undo(self) -> None:
self.project.mp_constraints[:] = [ 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() self._notify()
@ -48,7 +49,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
""" """
def __init__( def __init__(
self, vm: ProjectViewModel, index: int, new_constraint: EqualDOFConstraint self, vm: "ProjectViewModel", index: int, new_constraint: EqualDOFConstraint
) -> None: ) -> None:
super().__init__(vm, f"Edit equalDOF row {index + 1}") super().__init__(vm, f"Edit equalDOF row {index + 1}")
self._index = index self._index = index
@ -71,7 +72,7 @@ class UpdateEqualDOFConstraintCommand(ProjectCommand):
class DeleteEqualDOFConstraintCommand(ProjectCommand): class DeleteEqualDOFConstraintCommand(ProjectCommand):
"""Remove ``equalDOF`` constraints by table-row index (no cascade).""" """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)") super().__init__(vm, f"Delete {len(indices)} constraint(s)")
self._indices = set(indices) self._indices = set(indices)
self._removed: list[tuple[int, EqualDOFConstraint]] = [] 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.""" """Add one or more elements in a single undoable step."""
def __init__( def __init__(
self, vm: ProjectViewModel, elements: list[Any], *, text: str | None = None self, vm: "ProjectViewModel", elements: list[Any], *, text: str | None = None
) -> None: ) -> None:
super().__init__(vm, text or f"Add {len(elements)} element(s)") super().__init__(vm, text or f"Add {len(elements)} element(s)")
self._elements = list(elements) self._elements = list(elements)
@ -40,7 +40,7 @@ class AddElementsCommand(ProjectCommand):
class DeleteElementsCommand(ProjectCommand): class DeleteElementsCommand(ProjectCommand):
"""Remove a set of elements (no cascade — nodes are not affected).""" """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)") super().__init__(vm, f"Delete {len(element_ids)} element(s)")
self._element_ids = set(element_ids) self._element_ids = set(element_ids)
self._removed: list[tuple[int, Any]] = [] self._removed: list[tuple[int, Any]] = []
@ -68,7 +68,7 @@ class AssignSectionCommand(ProjectCommand):
(e.g. trusses, zero-length elements). (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)") super().__init__(vm, f"Assign section {section_id} to {len(element_ids)} element(s)")
self._element_ids = set(element_ids) self._element_ids = set(element_ids)
self._section_id = section_id self._section_id = section_id
@ -106,7 +106,7 @@ class ConvertElementTypeCommand(ProjectCommand):
def __init__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
element_ids: set[int], element_ids: set[int],
target_type: str, target_type: str,
defaults: dict[str, Any] | None = None, defaults: dict[str, Any] | None = None,
@ -185,7 +185,7 @@ class ReplaceElementsCommand(ProjectCommand):
for another while preserving the id and node connectivity. 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)") super().__init__(vm, f"Replace {len(replacements)} element(s)")
self._replacements = {el.id: el for el in replacements} self._replacements = {el.id: el for el in replacements}
self._previous: dict[int, Any] = {} self._previous: dict[int, Any] = {}
@ -217,7 +217,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
def __init__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
element_id: int, element_id: int,
fields: dict[str, Any], fields: dict[str, Any],
) -> None: ) -> None:
@ -254,7 +254,7 @@ class UpdateElementFieldsCommand(ProjectCommand):
class AssignMaterialCommand(ProjectCommand): class AssignMaterialCommand(ProjectCommand):
"""Set ``material_id`` on a set of elements (truss-style).""" """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)") super().__init__(vm, f"Assign material {material_id} to {len(element_ids)} element(s)")
self._element_ids = set(element_ids) self._element_ids = set(element_ids)
self._material_id = material_id self._material_id = material_id

View file

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

View file

@ -35,7 +35,7 @@ class AddNodalLoadsCommand(ProjectCommand):
def __init__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
node_ids: set[int], node_ids: set[int],
forces: tuple[float, float, float, float, float, float], forces: tuple[float, float, float, float, float, float],
pattern_id: int | None = None, pattern_id: int | None = None,
@ -101,10 +101,7 @@ class AddNodalLoadsCommand(ProjectCommand):
break break
self._added_loads.clear() self._added_loads.clear()
# Roll back any infrastructure we created. # Roll back any infrastructure we created.
if ( if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
self.project.load_patterns.remove(self._created_pattern) self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series: 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__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
element_ids: set[int], element_ids: set[int],
wy: float = 0.0, wy: float = 0.0,
wz: float = 0.0, wz: float = 0.0,
@ -160,10 +157,7 @@ class AddElementLoadsCommand(ProjectCommand):
pattern = self._resolve_pattern() pattern = self._resolve_pattern()
for eid in self._element_ids: for eid in self._element_ids:
load = UniformElementLoad( load = UniformElementLoad(
element_id=eid, element_id=eid, wy=self._wy, wz=self._wz, wx=self._wx,
wy=self._wy,
wz=self._wz,
wx=self._wx,
) )
pattern.element_loads.append(load) pattern.element_loads.append(load)
self._added_loads.append((pattern.id, load)) self._added_loads.append((pattern.id, load))
@ -177,10 +171,7 @@ class AddElementLoadsCommand(ProjectCommand):
pat.element_loads.remove(load) pat.element_loads.remove(load)
break break
self._added_loads.clear() self._added_loads.clear()
if ( if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
self.project.load_patterns.remove(self._created_pattern) self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series: 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): class AddMaterialsCommand(ProjectCommand):
"""Add one or more materials in a single undoable step.""" """Add one or more materials in a single undoable step."""
def __init__( def __init__(self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None) -> None:
self, vm: ProjectViewModel, materials: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(materials)} material(s)") super().__init__(vm, text or f"Add {len(materials)} material(s)")
self._materials = list(materials) self._materials = list(materials)
@ -36,14 +34,15 @@ class AddMaterialsCommand(ProjectCommand):
class DeleteMaterialsCommand(ProjectCommand): class DeleteMaterialsCommand(ProjectCommand):
"""Remove materials (no cascade).""" """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)") super().__init__(vm, f"Delete {len(material_ids)} material(s)")
self._material_ids = set(material_ids) self._material_ids = set(material_ids)
self._removed: list[tuple[int, Any]] = [] self._removed: list[tuple[int, Any]] = []
def redo(self) -> None: def redo(self) -> None:
self._removed = [ 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[:] = [ self.project.materials[:] = [
m for m in self.project.materials if m.id not in self._material_ids 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. 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}") super().__init__(vm, f"Edit material {new_material.id}")
self._new = new_material self._new = new_material
self._old: Any | None = None self._old: Any | None = None

View file

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

View file

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

View file

@ -13,9 +13,7 @@ if TYPE_CHECKING:
class AddSectionsCommand(ProjectCommand): class AddSectionsCommand(ProjectCommand):
"""Add one or more sections in a single undoable step.""" """Add one or more sections in a single undoable step."""
def __init__( def __init__(self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None) -> None:
self, vm: ProjectViewModel, sections: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(sections)} section(s)") super().__init__(vm, text or f"Add {len(sections)} section(s)")
self._sections = list(sections) self._sections = list(sections)
@ -36,14 +34,15 @@ class AddSectionsCommand(ProjectCommand):
class DeleteSectionsCommand(ProjectCommand): class DeleteSectionsCommand(ProjectCommand):
"""Remove sections (no cascade — elements referencing them must be cleaned separately).""" """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)") super().__init__(vm, f"Delete {len(section_ids)} section(s)")
self._section_ids = set(section_ids) self._section_ids = set(section_ids)
self._removed: list[tuple[int, Any]] = [] self._removed: list[tuple[int, Any]] = []
def redo(self) -> None: def redo(self) -> None:
self._removed = [ 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[:] = [ self.project.sections[:] = [
s for s in self.project.sections if s.id not in self._section_ids 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): class UpdateSectionCommand(ProjectCommand):
"""Replace a section's parameters at a given id.""" """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}") super().__init__(vm, f"Edit section {new_section.id}")
self._new = new_section self._new = new_section
self._old: Any | None = None self._old: Any | None = None

View file

@ -19,7 +19,7 @@ if TYPE_CHECKING:
class AddTimeSeriesCommand(ProjectCommand): class AddTimeSeriesCommand(ProjectCommand):
"""Append a :class:`TimeSeries` to the project (undoable).""" """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}'") super().__init__(vm, f"Add time series '{ts.name or ts.id}'")
self._ts = ts self._ts = ts
@ -31,7 +31,9 @@ class AddTimeSeriesCommand(ProjectCommand):
self._notify() self._notify()
def undo(self) -> None: 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() self._notify()
@ -41,7 +43,7 @@ class UpdateTimeSeriesCommand(ProjectCommand):
The new series must carry the same id as the one being replaced. 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}") super().__init__(vm, f"Edit time series {new_ts.id}")
self._new = new_ts self._new = new_ts
self._old: Any | None = None self._old: Any | None = None
@ -67,7 +69,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
"""Remove time series (no cascade — patterns referencing them must be """Remove time series (no cascade — patterns referencing them must be
cleaned separately; ``validate_references`` catches strays before save).""" 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)") super().__init__(vm, f"Delete {len(ts_ids)} time serie(s)")
self._ts_ids = set(ts_ids) self._ts_ids = set(ts_ids)
self._removed: list[tuple[int, Any]] = [] self._removed: list[tuple[int, Any]] = []
@ -91,7 +93,7 @@ class DeleteTimeSeriesCommand(ProjectCommand):
class AddLoadPatternCommand(ProjectCommand): class AddLoadPatternCommand(ProjectCommand):
"""Append a :class:`LoadPattern` to the project (undoable).""" """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}'") super().__init__(vm, f"Add pattern '{pattern.name or pattern.id}'")
self._pattern = pattern self._pattern = pattern
@ -117,7 +119,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
drops the pattern's stored loads. 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}") super().__init__(vm, f"Edit pattern {new_pattern.id}")
self._new = new_pattern self._new = new_pattern
self._old: Any | None = None self._old: Any | None = None
@ -142,7 +144,7 @@ class UpdateLoadPatternCommand(ProjectCommand):
class DeleteLoadPatternCommand(ProjectCommand): class DeleteLoadPatternCommand(ProjectCommand):
"""Remove load patterns (their child nodal/element loads go with them).""" """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)") super().__init__(vm, f"Delete {len(pattern_ids)} pattern(s)")
self._pattern_ids = set(pattern_ids) self._pattern_ids = set(pattern_ids)
self._removed: list[tuple[int, Any]] = [] 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 __future__ import annotations
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Any, Literal
from otko.commands.base import ProjectCommand from otko.commands.base import ProjectCommand
from otko.core import Node
if TYPE_CHECKING: if TYPE_CHECKING:
from otko.viewmodels import ProjectViewModel from otko.viewmodels import ProjectViewModel
@ -29,7 +30,7 @@ class MoveNodesCommand(ProjectCommand):
def __init__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
node_ids: set[int], node_ids: set[int],
offset: tuple[float, float, float], offset: tuple[float, float, float],
) -> None: ) -> None:
@ -44,9 +45,9 @@ class MoveNodesCommand(ProjectCommand):
for i, n in enumerate(self.project.nodes): for i, n in enumerate(self.project.nodes):
if n.id in self._node_ids: if n.id in self._node_ids:
self._previous[n.id] = n.coords self._previous[n.id] = n.coords
self.project.nodes[i] = n.model_copy( self.project.nodes[i] = n.model_copy(update={
update={"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)} "coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)
) })
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:
@ -62,7 +63,7 @@ class ReplicateCommand(ProjectCommand):
def __init__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
node_ids: set[int], node_ids: set[int],
element_ids: set[int], element_ids: set[int],
offset: tuple[float, float, float], offset: tuple[float, float, float],
@ -83,8 +84,7 @@ class ReplicateCommand(ProjectCommand):
# Snapshot the source nodes/elements once (won't change during redo). # 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_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [ src_elements = [
e e for e in self.project.elements
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) 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() next_node_id = self.project.next_node_id()
@ -95,34 +95,30 @@ class ReplicateCommand(ProjectCommand):
for k in range(1, self._n_copies + 1): for k in range(1, self._n_copies + 1):
mapping: dict[int, int] = {} mapping: dict[int, int] = {}
for orig in src_nodes: for orig in src_nodes:
new_node = orig.model_copy( new_node = orig.model_copy(update={
update={
"id": next_node_id, "id": next_node_id,
"coords": ( "coords": (orig.coords[0] + k * dx,
orig.coords[0] + k * dx,
orig.coords[1] + k * dy, orig.coords[1] + k * dy,
orig.coords[2] + k * dz, orig.coords[2] + k * dz),
), })
}
)
self.project.nodes.append(new_node) self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id) self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id mapping[orig.id] = next_node_id
next_node_id += 1 next_node_id += 1
for orig in src_elements: for orig in src_elements:
new_elem = orig.model_copy( new_elem = orig.model_copy(update={
update={
"id": next_elem_id, "id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes), "nodes": tuple(mapping[nid] for nid in orig.nodes),
} })
)
self.project.elements.append(new_elem) self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id) self._added_element_ids.add(next_elem_id)
next_elem_id += 1 next_elem_id += 1
self._notify() self._notify()
def undo(self) -> None: 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[:] = [ self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids 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__( def __init__(
self, self,
vm: ProjectViewModel, vm: "ProjectViewModel",
node_ids: set[int], node_ids: set[int],
element_ids: set[int], element_ids: set[int],
plane: Plane, plane: Plane,
@ -170,8 +166,7 @@ class MirrorCommand(ProjectCommand):
def redo(self) -> None: def redo(self) -> None:
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids] src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
src_elements = [ src_elements = [
e e for e in self.project.elements
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) 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() next_node_id = self.project.next_node_id()
@ -181,30 +176,28 @@ class MirrorCommand(ProjectCommand):
mapping: dict[int, int] = {} mapping: dict[int, int] = {}
for orig in src_nodes: for orig in src_nodes:
new_node = orig.model_copy( new_node = orig.model_copy(update={
update={
"id": next_node_id, "id": next_node_id,
"coords": self._reflect(orig.coords, self._plane), "coords": self._reflect(orig.coords, self._plane),
} })
)
self.project.nodes.append(new_node) self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id) self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id mapping[orig.id] = next_node_id
next_node_id += 1 next_node_id += 1
for orig in src_elements: for orig in src_elements:
new_elem = orig.model_copy( new_elem = orig.model_copy(update={
update={
"id": next_elem_id, "id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes), "nodes": tuple(mapping[nid] for nid in orig.nodes),
} })
)
self.project.elements.append(new_elem) self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id) self._added_element_ids.add(next_elem_id)
next_elem_id += 1 next_elem_id += 1
self._notify() self._notify()
def undo(self) -> None: 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[:] = [ self.project.elements[:] = [
e for e in self.project.elements if e.id not in self._added_element_ids 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, ConstantTimeSeries,
ImposedSupportMotionPattern, ImposedSupportMotionPattern,
LinearTimeSeries, LinearTimeSeries,
LoadCombination,
LoadCombinationItem,
LoadPattern, LoadPattern,
NodalLoad, NodalLoad,
PathTimeSeries, PathTimeSeries,
@ -167,8 +165,6 @@ __all__ = [
"PlainLoadPattern", "PlainLoadPattern",
"UniformExcitationPattern", "UniformExcitationPattern",
"ImposedSupportMotionPattern", "ImposedSupportMotionPattern",
"LoadCombination",
"LoadCombinationItem",
"NodalLoad", "NodalLoad",
"UniformElementLoad", "UniformElementLoad",
"ResponseSpectrum", "ResponseSpectrum",

View file

@ -22,7 +22,5 @@ class Entity(BaseModel):
populate_by_name=True, populate_by_name=True,
) )
id: PositiveInt = Field( id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.")
..., description="Unique tag within its kind. Used as the OpenSees tag."
)
name: str = Field(default="", description="Optional human-readable label.") name: str = Field(default="", description="Optional human-readable label.")

View file

@ -9,7 +9,7 @@ in Phase 6/8.
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Literal from typing import Annotated, Literal, Union
from pydantic import Field, PositiveFloat, PositiveInt, model_validator from pydantic import Field, PositiveFloat, PositiveInt, model_validator
@ -83,8 +83,7 @@ class TransientCase(Entity):
constraints: str = "Plain" constraints: str = "Plain"
integrator: str = "Newmark" integrator: str = "Newmark"
integrator_params: tuple[float, float] = Field( integrator_params: tuple[float, float] = Field(
default=(0.5, 0.25), default=(0.5, 0.25), description="Newmark gamma, beta (default = average acceleration).",
description="Newmark gamma, beta (default = average acceleration).",
) )
algorithm: str = "Newton" algorithm: str = "Newton"
test: str = "NormDispIncr" test: str = "NormDispIncr"
@ -115,11 +114,11 @@ class TransientCase(Entity):
) )
rayleigh_beta_k_comm: float = Field( rayleigh_beta_k_comm: float = Field(
default=0.0, 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( rayleigh_mode1_damping: float | None = Field(
default=None, default=None, ge=0.0,
ge=0.0,
description=( description=(
"If set, βK is computed as 2·ζ/√λ₁ (first-mode eigenvalue) and " "If set, βK is computed as 2·ζ/√λ₁ (first-mode eigenvalue) and "
"overrides ``rayleigh_beta_k``. ``rayleigh_alpha_m`` still applies." "overrides ``rayleigh_beta_k``. ``rayleigh_alpha_m`` still applies."
@ -245,30 +244,23 @@ class ResponseSpectrumCase(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum" type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
modal_case_id: PositiveInt = Field( 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( spectrum_id: PositiveInt = Field(
..., ..., description="ID of the ResponseSpectrum to apply.",
description="ID of the ResponseSpectrum to apply.",
) )
direction: int = Field( 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" combination: Literal["SRSS", "CQC"] = "SRSS"
damping_ratio: float | None = Field( damping_ratio: float | None = Field(
default=None, default=None, ge=0.0, le=1.0,
ge=0.0,
le=1.0,
description="Override the spectrum's damping for CQC correlation. " 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[ AnalysisCase = Annotated[
StaticCase | ModalCase | TransientCase | PushoverCase | ResponseSpectrumCase, Union[StaticCase, ModalCase, TransientCase, PushoverCase, ResponseSpectrumCase],
Field(discriminator="type"), Field(discriminator="type"),
] ]

View file

@ -159,8 +159,12 @@ def ensure_default_timeseries_and_pattern(
if existing is not None: if existing is not None:
return existing return existing
pattern_name = name or DEFAULT_PATTERN_NAME 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) 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) project.load_patterns.append(pattern)
return pattern return pattern

View file

@ -10,7 +10,7 @@ Conventions follow OpenSeesPy ``element ...`` commands.
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Literal from typing import Annotated, Literal, Union
from pydantic import Field, PositiveFloat, PositiveInt from pydantic import Field, PositiveFloat, PositiveInt
@ -91,9 +91,7 @@ class ZeroLengthElement(Entity):
type: Literal["ZeroLength"] = "ZeroLength" type: Literal["ZeroLength"] = "ZeroLength"
nodes: tuple[PositiveInt, PositiveInt] nodes: tuple[PositiveInt, PositiveInt]
material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1) material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1)
dofs: tuple[int, ...] = Field( dofs: tuple[int, ...] = Field(..., min_length=1, description="DOF directions, 1-indexed (1..6).")
..., min_length=1, description="DOF directions, 1-indexed (1..6)."
)
do_rayleigh: bool = Field( do_rayleigh: bool = Field(
default=False, default=False,
description=( description=(
@ -123,9 +121,7 @@ class ZeroLengthSectionElement(Entity):
type: Literal["ZeroLengthSection"] = "ZeroLengthSection" type: Literal["ZeroLengthSection"] = "ZeroLengthSection"
nodes: tuple[PositiveInt, PositiveInt] nodes: tuple[PositiveInt, PositiveInt]
section_id: PositiveInt = Field( section_id: PositiveInt = Field(..., description="Section attached to the two coincident nodes.")
..., description="Section attached to the two coincident nodes."
)
class BeamWithHingesElement(Entity): class BeamWithHingesElement(Entity):
@ -183,8 +179,7 @@ class QuadElement(Entity):
description="Surface pressure applied over the element (force / area).", description="Surface pressure applied over the element (force / area).",
) )
rho: float = Field( rho: float = Field(
default=0.0, default=0.0, ge=0.0,
ge=0.0,
description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.", description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.",
) )
b1: float = Field( b1: float = Field(
@ -198,14 +193,16 @@ class QuadElement(Entity):
Element = Annotated[ Element = Annotated[
TrussElement Union[
| CorotTrussElement TrussElement,
| ElasticBeamColumn CorotTrussElement,
| ForceBeamColumn ElasticBeamColumn,
| DispBeamColumn ForceBeamColumn,
| ZeroLengthElement DispBeamColumn,
| ZeroLengthSectionElement ZeroLengthElement,
| BeamWithHingesElement ZeroLengthSectionElement,
| QuadElement, BeamWithHingesElement,
QuadElement,
],
Field(discriminator="type"), Field(discriminator="type"),
] ]

View file

@ -87,17 +87,15 @@ class GridSystem(BaseModel):
if isinstance(v, dict): if isinstance(v, dict):
records.append(v) records.append(v)
else: else:
records.append( records.append({
{
"id": f"{prefix}{i + 1}", "id": f"{prefix}{i + 1}",
"ordinate": float(v), "ordinate": float(v),
} })
)
data[grid_key] = records data[grid_key] = records
return data return data
@model_validator(mode="after") @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"): for name in ("x_grid_lines", "y_grid_lines", "z_grid_lines"):
lines: list[GridLine] = list(getattr(self, name)) lines: list[GridLine] = list(getattr(self, name))
lines.sort(key=lambda ln: ln.ordinate) 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]]: def bounds(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
"""Return ((xmin, xmax), (ymin, ymax), (zmin, zmax)) spanning the grid.""" """Return ((xmin, xmax), (ymin, ymax), (zmin, zmax)) spanning the grid."""
def span(vs: list[float]) -> tuple[float, float]: def span(vs: list[float]) -> tuple[float, float]:
if not vs: if not vs:
return (0.0, 0.0) return (0.0, 0.0)
return (vs[0], vs[-1]) return (vs[0], vs[-1])
return span(self.x_lines), span(self.y_lines), span(self.z_lines) return span(self.x_lines), span(self.y_lines), span(self.z_lines)
def make_grid_lines( def make_grid_lines(
axis: Literal["X", "Y", "Z"], axis: Literal["X", "Y", "Z"], ordinates: list[float],
ordinates: list[float],
) -> list[GridLine]: ) -> list[GridLine]:
"""Helper: build default-metadata GridLine records from flat ordinates.""" """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): class CoordinateSystem(BaseModel):

View file

@ -12,9 +12,9 @@ pattern is deleted, its loads go with it.
from __future__ import annotations 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 from otko.core._base import Entity
@ -77,19 +77,15 @@ class ResponseSpectrum(Entity):
type: Literal["ResponseSpectrum"] = "ResponseSpectrum" type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
periods: list[float] = Field( periods: list[float] = Field(
..., ..., min_length=2,
min_length=2,
description="Periods (s), strictly increasing.", description="Periods (s), strictly increasing.",
) )
accelerations: list[float] = Field( accelerations: list[float] = Field(
..., ..., min_length=2,
min_length=2,
description="Spectral pseudo-accelerations (length must match `periods`).", description="Spectral pseudo-accelerations (length must match `periods`).",
) )
damping_ratio: float = Field( damping_ratio: float = Field(
default=0.05, default=0.05, ge=0.0, le=1.0,
ge=0.0,
le=1.0,
description="Modal damping ratio the spectrum was built for.", description="Modal damping ratio the spectrum was built for.",
) )
@ -110,7 +106,7 @@ class ResponseSpectrum(Entity):
TimeSeries = Annotated[ TimeSeries = Annotated[
LinearTimeSeries | ConstantTimeSeries | PathTimeSeries, Union[LinearTimeSeries, ConstantTimeSeries, PathTimeSeries],
Field(discriminator="type"), Field(discriminator="type"),
] ]
@ -189,67 +185,13 @@ class ImposedSupportMotionPattern(Entity):
direction: int = Field(..., ge=1, le=6, description="DOF direction (1..6).") direction: int = Field(..., ge=1, le=6, description="DOF direction (1..6).")
disp_series_id: PositiveInt disp_series_id: PositiveInt
node_ids: list[int] = Field( node_ids: list[int] = Field(
..., ..., min_length=1,
min_length=1,
description="Support nodes driven by the motion (each restrained in `direction`).", description="Support nodes driven by the motion (each restrained in `direction`).",
) )
factor: float = 1.0 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[ LoadPattern = Annotated[
PlainLoadPattern | UniformExcitationPattern | ImposedSupportMotionPattern, Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
Field(discriminator="type"), Field(discriminator="type"),
] ]

View file

@ -11,7 +11,7 @@ https://openseespydoc.readthedocs.io/en/latest/src/uniaxialMaterial.html
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Literal from typing import Annotated, Literal, Union
from pydantic import Field, PositiveFloat, model_validator from pydantic import Field, PositiveFloat, model_validator
@ -58,9 +58,7 @@ class Steel02(Entity):
Fy: PositiveFloat Fy: PositiveFloat
E0: PositiveFloat E0: PositiveFloat
b: float = Field(..., ge=0.0, le=1.0) b: float = Field(..., ge=0.0, le=1.0)
R0: float = Field( R0: float = Field(default=18.0, description="Bauschinger curvature parameter (typically 10–20).")
default=18.0, description="Bauschinger curvature parameter (typically 10–20)."
)
cR1: float = Field(default=0.925) cR1: float = Field(default=0.925)
cR2: float = Field(default=0.15) cR2: float = Field(default=0.15)
@ -85,10 +83,7 @@ class Concrete02(Entity):
fpcu: float = Field(..., le=0.0) fpcu: float = Field(..., le=0.0)
epsU: float = Field(..., lt=0.0) epsU: float = Field(..., lt=0.0)
lambda_: float = Field( 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.", description="Ratio between unloading slope at epscu and initial slope.",
) )
ft: PositiveFloat = Field(..., description="Tensile strength.") ft: PositiveFloat = Field(..., description="Tensile strength.")
@ -113,24 +108,20 @@ class Concrete04(Entity):
epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).") epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).")
Ec: PositiveFloat = Field(..., description="Initial tangent modulus.") Ec: PositiveFloat = Field(..., description="Initial tangent modulus.")
fct: float | None = Field( fct: float | None = Field(
default=None, default=None, gt=0.0,
gt=0.0,
description="Maximum tensile strength. Omit for no-tension model.", description="Maximum tensile strength. Omit for no-tension model.",
) )
et: float | None = Field( et: float | None = Field(
default=None, default=None, gt=0.0,
gt=0.0,
description="Ultimate tensile strain. Required when fct is given.", description="Ultimate tensile strain. Required when fct is given.",
) )
beta: float | None = Field( beta: float | None = Field(
default=None, default=None, ge=0.0, le=1.0,
ge=0.0,
le=1.0,
description="Cyclic degradation factor on unloading stiffness. Requires fct and et.", description="Cyclic degradation factor on unloading stiffness. Requires fct and et.",
) )
@model_validator(mode="after") @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: if self.fct is not None and self.et is None:
raise ValueError("et is required when fct is given.") raise ValueError("et is required when fct is given.")
if self.et is not None and self.fct is None: if self.et is not None and self.fct is None:
@ -147,8 +138,7 @@ class ElasticPP(Entity):
E: PositiveFloat E: PositiveFloat
epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.") epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.")
epsy_neg: float | None = Field( epsy_neg: float | None = Field(
default=None, default=None, description="Yield strain in compression (negative); defaults to -epsy_pos.",
description="Yield strain in compression (negative); defaults to -epsy_pos.",
) )
eps0: float = Field(default=0.0, description="Initial strain.") 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.") 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.") d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.")
beta: float = Field( beta: float = Field(
default=0.0, default=0.0, ge=0.0,
ge=0.0,
description="Unloading-stiffness degradation (0 = no degradation).", description="Unloading-stiffness degradation (0 = no degradation).",
) )
@ -217,8 +206,7 @@ class HystereticSM(Entity):
type: Literal["HystereticSM"] = "HystereticSM" type: Literal["HystereticSM"] = "HystereticSM"
pos_env: list[tuple[float, float]] = Field( pos_env: list[tuple[float, float]] = Field(
..., ..., min_length=1,
min_length=1,
description="Positive envelope (force, deformation) pairs, force first.", description="Positive envelope (force, deformation) pairs, force first.",
) )
neg_env: list[tuple[float, float]] = Field( neg_env: list[tuple[float, float]] = Field(
@ -229,16 +217,18 @@ class HystereticSM(Entity):
# ──────────────────────────── Discriminated union ──────────────────────────── # ──────────────────────────── Discriminated union ────────────────────────────
Material = Annotated[ Material = Annotated[
ElasticIsotropic Union[
| ElasticUniaxial ElasticIsotropic,
| Steel01 ElasticUniaxial,
| Steel02 Steel01,
| Concrete01 Steel02,
| Concrete02 Concrete01,
| Concrete04 Concrete02,
| ElasticPP Concrete04,
| HystereticMaterial ElasticPP,
| HystereticSM, HystereticMaterial,
HystereticSM,
],
Field(discriminator="type"), Field(discriminator="type"),
] ]
"""Tagged union of every material kind. Pydantic uses ``type`` to dispatch on JSON load.""" """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, Node,
default_global_system, 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.materials import Material
from otko.core.sections import Section from otko.core.sections import Section
from otko.core.units import UnitSystem from otko.core.units import UnitSystem
@ -110,7 +110,6 @@ _INDEX_KINDS: tuple[str, ...] = (
"load_patterns", "load_patterns",
"spectra", "spectra",
"analyses", "analyses",
"combinations",
) )
@ -156,17 +155,15 @@ class Project(BaseModel):
""" """
if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data: if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data:
legacy = data.pop("grid_system") legacy = data.pop("grid_system")
data["coord_systems"] = [ data["coord_systems"] = [{
{
"name": "Global", "name": "Global",
"coord": {}, "coord": {},
"grid": legacy, "grid": legacy,
} }]
]
return data return data
@model_validator(mode="after") @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.""" """Guarantee that a 'Global' entry exists as the first coord system."""
has_global = any(cs.name == "Global" for cs in self.coord_systems) has_global = any(cs.name == "Global" for cs in self.coord_systems)
if not has_global: if not has_global:
@ -174,7 +171,7 @@ class Project(BaseModel):
return self return self
@model_validator(mode="after") @model_validator(mode="after")
def _ensure_indexed(self) -> Project: def _ensure_indexed(self) -> "Project":
"""Wrap every collection in an :class:`_IndexedList` and drop caches. """Wrap every collection in an :class:`_IndexedList` and drop caches.
Runs after each validation pass (construction, ``model_validate``, Runs after each validation pass (construction, ``model_validate``,
@ -206,12 +203,10 @@ class Project(BaseModel):
return return
# No Global system yet — create one with this grid. # No Global system yet — create one with this grid.
from otko.core.geometry import CoordinateGridSystem from otko.core.geometry import CoordinateGridSystem
self.coord_systems.insert( self.coord_systems.insert(
0, 0,
CoordinateGridSystem(name="Global", grid=new_grid), CoordinateGridSystem(name="Global", grid=new_grid),
) )
sections: list[Section] = Field(default_factory=list) sections: list[Section] = Field(default_factory=list)
elements: list[Element] = Field(default_factory=list) elements: list[Element] = Field(default_factory=list)
mp_constraints: list[EqualDOFConstraint] = 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) load_patterns: list[LoadPattern] = Field(default_factory=list)
spectra: list[ResponseSpectrum] = Field(default_factory=list) spectra: list[ResponseSpectrum] = Field(default_factory=list)
analyses: list[AnalysisCase] = Field(default_factory=list) analyses: list[AnalysisCase] = Field(default_factory=list)
combinations: list[LoadCombination] = Field(default_factory=list)
# ─────────────────── invariants ─────────────────── # ─────────────────── invariants ───────────────────
@model_validator(mode="after") @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)} valid = {(2, 2), (2, 3), (3, 3), (3, 6)}
if (self.ndm, self.ndf) not in valid: if (self.ndm, self.ndf) not in valid:
raise ValueError( raise ValueError(
@ -233,7 +227,7 @@ class Project(BaseModel):
return self return self
@model_validator(mode="after") @model_validator(mode="after")
def _check_unique_ids(self) -> Project: def _check_unique_ids(self) -> "Project":
for label, items in ( for label, items in (
("node", self.nodes), ("node", self.nodes),
("material", self.materials), ("material", self.materials),
@ -242,7 +236,6 @@ class Project(BaseModel):
("time series", self.time_series), ("time series", self.time_series),
("load pattern", self.load_patterns), ("load pattern", self.load_patterns),
("analysis", self.analyses), ("analysis", self.analyses),
("combination", self.combinations),
): ):
ids = [it.id for it in items] ids = [it.id for it in items]
if len(ids) != len(set(ids)): if len(ids) != len(set(ids)):
@ -277,10 +270,6 @@ class Project(BaseModel):
def next_analysis_id(self) -> int: def next_analysis_id(self) -> int:
return self._next_id(self.analyses) 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 ─────────────────── # ─────────────────── lookups ───────────────────
def node(self, node_id: PositiveInt) -> Node: def node(self, node_id: PositiveInt) -> Node:
return cast(Node, self._lookup("nodes", self.nodes, node_id, "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: def element(self, element_id: PositiveInt) -> Element:
return cast(Element, self._lookup("elements", self.elements, element_id, "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: 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. """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}." 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: if problems:
raise ValueError("Reference validation failed:\n - " + "\n - ".join(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 __future__ import annotations
from typing import Annotated, Literal from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt from pydantic import BaseModel, ConfigDict, Field, PositiveFloat, PositiveInt
@ -61,7 +61,7 @@ class RectShape(BaseModel):
SectionShape = Annotated[ SectionShape = Annotated[
PipeShape | AngleShape | RectShape, Union[PipeShape, AngleShape, RectShape],
Field(discriminator="kind"), Field(discriminator="kind"),
] ]
@ -75,12 +75,8 @@ class ElasticSection(Entity):
A: PositiveFloat A: PositiveFloat
Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.") Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.")
Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.") Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.")
G: PositiveFloat | None = Field( G: PositiveFloat | None = Field(default=None, description="Shear modulus; required for 3D frames.")
default=None, description="Shear modulus; required for 3D frames." J: PositiveFloat | None = Field(default=None, description="Torsional constant; required for 3D frames.")
)
J: PositiveFloat | None = Field(
default=None, description="Torsional constant; required for 3D frames."
)
shape: SectionShape | None = Field( shape: SectionShape | None = Field(
default=None, default=None,
description=( description=(
@ -165,12 +161,12 @@ class StraightLayer(BaseModel):
Patch = Annotated[ Patch = Annotated[
RectangularPatch | CircularPatch, Union[RectangularPatch, CircularPatch],
Field(discriminator="kind"), Field(discriminator="kind"),
] ]
Layer = Annotated[ Layer = Annotated[
StraightLayer, Union[StraightLayer],
Field(discriminator="kind"), Field(discriminator="kind"),
] ]
@ -202,8 +198,7 @@ class AggregatorDOF(BaseModel):
material_id: PositiveInt material_id: PositiveInt
dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field( 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[ Section = Annotated[
ElasticSection | FiberSection | SectionAggregator, Union[ElasticSection, FiberSection, SectionAggregator],
Field(discriminator="type"), Field(discriminator="type"),
] ]

View file

@ -52,36 +52,20 @@ class UnitLabels:
_LABELS: dict[UnitSystem, UnitLabels] = { _LABELS: dict[UnitSystem, UnitLabels] = {
UnitSystem.SI_M_N: UnitLabels( UnitSystem.SI_M_N: UnitLabels(
length="m", length="m", force="N", moment="N·m",
force="N", stress="Pa", curvature="1/m", rotation="rad",
moment="N·m",
stress="Pa",
curvature="1/m",
rotation="rad",
), ),
UnitSystem.SI_MM_N: UnitLabels( UnitSystem.SI_MM_N: UnitLabels(
length="mm", length="mm", force="N", moment="N·mm",
force="N", stress="MPa", curvature="1/mm", rotation="rad",
moment="N·mm",
stress="MPa",
curvature="1/mm",
rotation="rad",
), ),
UnitSystem.US_FT_KIP: UnitLabels( UnitSystem.US_FT_KIP: UnitLabels(
length="ft", length="ft", force="kip", moment="kip·ft",
force="kip", stress="ksf", curvature="1/ft", rotation="rad",
moment="kip·ft",
stress="ksf",
curvature="1/ft",
rotation="rad",
), ),
UnitSystem.US_IN_KIP: UnitLabels( UnitSystem.US_IN_KIP: UnitLabels(
length="in", length="in", force="kip", moment="kip·in",
force="kip", stress="ksi", curvature="1/in", rotation="rad",
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. 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.export import export_opspy, export_tcl
from otko.services.material_tester import ( from otko.services.material_tester import (
CyclicSegment, CyclicSegment,
@ -34,16 +28,12 @@ from otko.services.results import (
__all__ = [ __all__ = [
"PROJECT_FILE_SUFFIX", "PROJECT_FILE_SUFFIX",
"CyclicSegment", "CyclicSegment",
"EnvelopeResults",
"LoadProtocol", "LoadProtocol",
"MaterialTestResult", "MaterialTestResult",
"ModalResults", "ModalResults",
"OpenSeesRunner", "OpenSeesRunner",
"StaticResults", "StaticResults",
"TransientResults", "TransientResults",
"combine_static_results",
"envelope_static_results",
"evaluate_combination",
"export_opspy", "export_opspy",
"export_tcl", "export_tcl",
"load_project", "load_project",

View file

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

View file

@ -12,9 +12,8 @@ the main thread.
from __future__ import annotations from __future__ import annotations
import math import math
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Callable
import numpy as np import numpy as np
@ -56,7 +55,8 @@ def export_mode_shape_video(
else: else:
# MP4 / WebM use FFmpeg backend. macro_block_size=1 disables # MP4 / WebM use FFmpeg backend. macro_block_size=1 disables
# the auto-resize-for-h264 pass (still works on modern players). # 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( def export_time_history_video(
@ -94,4 +94,5 @@ def export_time_history_video(
if suffix == ".gif": if suffix == ".gif":
iio.imwrite(output_path, frames, duration=1.0 / fps, loop=0) iio.imwrite(output_path, frames, duration=1.0 / fps, loop=0)
else: 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

@ -38,11 +38,8 @@ class DeformationSource:
def static_to_deformation( def static_to_deformation(
project: Project, project: Project, results: StaticResults, *,
results: StaticResults, step: int = -1, scale: float = 1.0,
*,
step: int = -1,
scale: float = 1.0,
) -> DeformationSource: ) -> DeformationSource:
"""Build a DeformationSource from a static analysis's nodal displacements. """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). # Take only translation DOFs (first 2 in 2D, first 3 in 3D).
n_take = min(3, snapshot.shape[0]) n_take = min(3, snapshot.shape[0])
disp[node_id_to_row[nid], :n_take] = snapshot[:n_take] 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( def modal_to_deformation(
project: Project, project: Project, results: ModalResults, *,
results: ModalResults, mode: int = 0, scale: float = 1.0, phase: float = 1.0,
*,
mode: int = 0,
scale: float = 1.0,
phase: float = 1.0,
) -> DeformationSource: ) -> DeformationSource:
"""Build a DeformationSource from a modal analysis's mode shape. """Build a DeformationSource from a modal analysis's mode shape.
@ -84,7 +78,8 @@ def modal_to_deformation(
mode_number = mode + 1 # mode_shapes is 1-indexed mode_number = mode + 1 # mode_shapes is 1-indexed
if mode_number not in results.mode_shapes: 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(): for nid, vec in eigvec.items():
@ -106,15 +101,12 @@ def modal_to_deformation(
norm_factor = (bbox * 0.05) / max_amp norm_factor = (bbox * 0.05) / max_amp
disp *= norm_factor 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( def transient_to_deformation_at_step(
project: Project, project: Project, results, *, step: int = 0, scale: float = 1.0,
results,
*,
step: int = 0,
scale: float = 1.0,
) -> DeformationSource: ) -> DeformationSource:
"""Build a DeformationSource from a transient analysis at one step. """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 norm_factor = (bbox * 0.10) / max_amp
disp *= norm_factor 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: def linear_static_auto_scale(project: Project, results: StaticResults) -> float:

View file

@ -51,26 +51,17 @@ class ForceComponent(Enum):
# Map (component, end) → index into the local-force vector for 3D and 2D. # Map (component, end) → index into the local-force vector for 3D and 2D.
_INDEX_3D = { _INDEX_3D = {
(ForceComponent.N, "i"): 0, (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 6,
(ForceComponent.N, "j"): 6, (ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 7,
(ForceComponent.V2, "i"): 1, (ForceComponent.V3, "i"): 2, (ForceComponent.V3, "j"): 8,
(ForceComponent.V2, "j"): 7, (ForceComponent.T, "i"): 3, (ForceComponent.T, "j"): 9,
(ForceComponent.V3, "i"): 2, (ForceComponent.M2, "i"): 4, (ForceComponent.M2, "j"): 10,
(ForceComponent.V3, "j"): 8, (ForceComponent.M3, "i"): 5, (ForceComponent.M3, "j"): 11,
(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 = { _INDEX_2D = {
(ForceComponent.N, "i"): 0, (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
(ForceComponent.N, "j"): 3, (ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 4,
(ForceComponent.V2, "i"): 1, (ForceComponent.M3, "i"): 2, (ForceComponent.M3, "j"): 5,
(ForceComponent.V2, "j"): 4,
(ForceComponent.M3, "i"): 2,
(ForceComponent.M3, "j"): 5,
} }
# Truss elements expose a different localForce layout than frames: # 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] # 3D truss → 6-vector [N_i, 0, 0, N_j, 0, 0]
# So we map only the N component; other components return None. # So we map only the N component; other components return None.
_INDEX_TRUSS_2D = { _INDEX_TRUSS_2D = {
(ForceComponent.N, "i"): 0, (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 2,
(ForceComponent.N, "j"): 2,
} }
_INDEX_TRUSS_3D = { _INDEX_TRUSS_3D = {
(ForceComponent.N, "i"): 0, (ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
(ForceComponent.N, "j"): 3,
} }
@ -133,7 +122,6 @@ def extract_diagram_data(
# Local import to avoid a cycle (element classes live in core.geometry). # Local import to avoid a cycle (element classes live in core.geometry).
from otko.core import CorotTrussElement, TrussElement from otko.core import CorotTrussElement, TrussElement
truss_types = (TrussElement, CorotTrussElement) truss_types = (TrussElement, CorotTrussElement)
for el in project.elements: for el in project.elements:

View file

@ -35,7 +35,7 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
from otko._const import OPENSEESPY_VERSION, __version__ from otko import __version__
from otko.core import ( from otko.core import (
ModalCase, ModalCase,
Project, Project,
@ -49,7 +49,7 @@ from otko.services.opensees_runner import OpenSeesRunner
__all__ = ["RecordingOps", "export_opspy", "export_tcl"] __all__ = ["RecordingOps", "export_opspy", "export_tcl"]
#: Pinned solver version written into every script header. #: 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). #: Recorded call triple: (ops method name, positional args, keyword args).
RecordedCall = tuple[str, tuple[Any, ...], dict[str, Any]] 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() runner.build()
else: else:
case = _find_case(project, case_id) 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 # Safe to run end-to-end: dummy query results let the
# harvesting code finish; queries are filtered at render. # harvesting code finish; queries are filtered at render.
runner.run(case) runner.run(case)
@ -362,7 +362,7 @@ def export_opspy(project: Project, case_id: int | None = None) -> str:
are appended after the model. are appended after the model.
Returns: 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. the OTKO version and the display units.
Raises: Raises:

View file

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

View file

@ -11,7 +11,6 @@ building the PathTimeSeries.
from __future__ import annotations from __future__ import annotations
import contextlib
import re import re
from pathlib import Path from pathlib import Path
@ -47,8 +46,7 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
break break
# New NGA format: "3930 0.00500 NPTS, DT" # New NGA format: "3930 0.00500 NPTS, DT"
m_new = re.match( m_new = re.match(
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", stripped,
stripped,
) )
if m_new: if m_new:
npts = int(m_new.group(1)) 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] = [] values: list[float] = []
for line in lines[data_start:]: for line in lines[data_start:]:
for tok in line.split(): for tok in line.split():
# Skip stray non-numeric tokens. try:
with contextlib.suppress(ValueError):
values.append(float(tok)) values.append(float(tok))
except ValueError:
pass # skip stray tokens
if not values: if not values:
raise ValueError("Header parsed but no numeric data lines found.") raise ValueError("Header parsed but no numeric data lines found.")
return dt, npts, values return dt, npts, values
@ -83,8 +82,10 @@ def parse_plain_values(path: str | Path) -> list[float]:
vals: list[float] = [] vals: list[float] = []
for line in text.splitlines(): for line in text.splitlines():
for tok in line.split(): for tok in line.split():
with contextlib.suppress(ValueError): try:
vals.append(float(tok)) vals.append(float(tok))
except ValueError:
pass
if not vals: if not vals:
raise ValueError(f"{path} contains no numeric values.") raise ValueError(f"{path} contains no numeric values.")
return vals return vals

View file

@ -52,10 +52,8 @@ class AnalysisWorker(QObject):
"""Slot to be invoked by ``QThread.started``.""" """Slot to be invoked by ``QThread.started``."""
self.started.emit() self.started.emit()
try: try:
self.log.emit( self.log.emit(f"Building model: {len(self._project.nodes)} nodes, "
f"Building model: {len(self._project.nodes)} nodes, " f"{len(self._project.elements)} elements.")
f"{len(self._project.elements)} elements."
)
runner = OpenSeesRunner(self._project) runner = OpenSeesRunner(self._project)
self.log.emit(f"Running case '{self._case.name}' ({type(self._case).__name__}) ...") self.log.emit(f"Running case '{self._case.name}' ({type(self._case).__name__}) ...")
results = runner.run(self._case, results_dir=self._results_dir) results = runner.run(self._case, results_dir=self._results_dir)

View file

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

View file

@ -21,6 +21,7 @@ import numpy as np
from otko.core.sections import ( from otko.core.sections import (
CircularPatch, CircularPatch,
FiberSection, FiberSection,
Fibre,
RectangularPatch, RectangularPatch,
StraightLayer, StraightLayer,
) )
@ -64,11 +65,9 @@ def expand_fibres(sec: FiberSection) -> np.ndarray:
yc = p.y_center + r_mid * math.cos(theta) yc = p.y_center + r_mid * math.cos(theta)
zc = p.z_center + r_mid * math.sin(theta) zc = p.z_center + r_mid * math.sin(theta)
# Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad) # Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad)
a = ( a = ((p.r_inner + (ir + 1) * d_r) ** 2
((p.r_inner + (ir + 1) * d_r) ** 2 - (p.r_inner + ir * d_r) ** 2) - (p.r_inner + ir * d_r) ** 2) \
* math.radians(d_theta) * math.radians(d_theta) / 2.0
/ 2.0
)
rows.append((yc, zc, a)) rows.append((yc, zc, a))
for lay in sec.layers: for lay in sec.layers:

View file

@ -37,9 +37,7 @@ class ModeContribution:
def mass_participation( def mass_participation(
project: Project, project: Project, modal: ModalResults, direction: int,
modal: ModalResults,
direction: int,
) -> list[ModeContribution]: ) -> list[ModeContribution]:
"""Compute Γ_i, M_eff,i and frequency for every mode. """Compute Γ_i, M_eff,i and frequency for every mode.
@ -86,8 +84,7 @@ def mass_participation(
omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1]))) omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1])))
period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf") 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 ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0
out.append( out.append(ModeContribution(
ModeContribution(
mode_number=mode_number, mode_number=mode_number,
period=period, period=period,
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0, frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
@ -96,8 +93,7 @@ def mass_participation(
effective_mass=m_eff, effective_mass=m_eff,
mass_ratio=ratio, mass_ratio=ratio,
sa_at_period=0.0, # filled in by combine_spectrum sa_at_period=0.0, # filled in by combine_spectrum
) ))
)
return out return out
@ -141,7 +137,8 @@ def combine_modal_response(
m.sa_at_period = 0.0 m.sa_at_period = 0.0
continue continue
m.sa_at_period = interp_sa(spectrum, m.period) 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] shape = modal.mode_shapes[m.mode_number]
for nid, vec in shape.items(): for nid, vec in shape.items():
n_take = min(3, vec.size) n_take = min(3, vec.size)

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ needing a reference to the renderer.
from __future__ import annotations from __future__ import annotations
import os
from typing import Any from typing import Any
import numpy as np 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.selection import SelectionState
from otko.views.canvas3d.style import RenderStyle 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] class ModelCanvas(QtInteractor): # type: ignore[misc]
@ -126,7 +127,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
cx = qt_x * dpr cx = qt_x * dpr
cy = (h_logical - qt_y) * dpr cy = (h_logical - qt_y) * dpr
if PICK_DEBUG: if _PICK_DEBUG:
print( print(
f"[pick] click qt=({qt_x:.0f},{qt_y:.0f}) → vtk=({cx:.0f},{cy:.0f}) " 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}" 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): if node_screen is not None and len(node_screen):
d2 = (node_screen[:, 0] - cx) ** 2 + (node_screen[:, 1] - cy) ** 2 d2 = (node_screen[:, 0] - cx) ** 2 + (node_screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2)) idx = int(np.argmin(d2))
if PICK_DEBUG: if _PICK_DEBUG:
print( print(
f"[pick] nearest node id={node_ids[idx]} " f"[pick] nearest node id={node_ids[idx]} "
f"screen={node_screen[idx]} d={np.sqrt(d2[idx]):.1f}px " 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 closest = a_screen + t[:, None] * ab
d2 = ((p - closest) ** 2).sum(axis=1) d2 = ((p - closest) ** 2).sum(axis=1)
idx = int(np.argmin(d2)) idx = int(np.argmin(d2))
if PICK_DEBUG: if _PICK_DEBUG:
print( print(
f"[pick] nearest frame id={frame_ids[idx]} " f"[pick] nearest frame id={frame_ids[idx]} "
f"d={float(np.sqrt(d2[idx])):.1f}px " 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])) self._dispatch_pick("element", int(frame_ids[idx]))
return return
if PICK_DEBUG: if _PICK_DEBUG:
print("[pick] no hit within tolerance") print("[pick] no hit within tolerance")
# ── Empty-click fallback: pixel-space snap to grid intersections. ── # ── 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) snapped = self._nearest_grid_intersection_px(cx, cy, grid_tol_px)
if snapped is not None: if snapped is not None:
self.emptyClicked.emit(float(snapped[0]), float(snapped[1]), float(snapped[2])) 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") print("[pick] off-grid click — no snap target within tolerance")
def _grid_intersections_world(self) -> np.ndarray | None: def _grid_intersections_world(self) -> np.ndarray | None:
@ -259,7 +260,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
return None return None
d2 = (screen[:, 0] - cx) ** 2 + (screen[:, 1] - cy) ** 2 d2 = (screen[:, 0] - cx) ** 2 + (screen[:, 1] - cy) ** 2
idx = int(np.argmin(d2)) idx = int(np.argmin(d2))
if PICK_DEBUG: if _PICK_DEBUG:
print( print(
f"[grid-snap] nearest intersection " f"[grid-snap] nearest intersection "
f"world={world_points[idx]} d={float(np.sqrt(d2[idx])):.1f}px " 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 from __future__ import annotations
import contextlib
import enum import enum
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, ClassVar from typing import Any
import numpy as np import numpy as np
import pyvista as pv import pyvista as pv
@ -25,11 +24,11 @@ from otko.core import (
ElasticBeamColumn, ElasticBeamColumn,
ForceBeamColumn, ForceBeamColumn,
NodalLoad, NodalLoad,
QuadElement,
UniformElementLoad,
PlainLoadPattern, PlainLoadPattern,
Project, Project,
QuadElement,
TrussElement, TrussElement,
UniformElementLoad,
ZeroLengthElement, ZeroLengthElement,
ZeroLengthSectionElement, ZeroLengthSectionElement,
) )
@ -185,8 +184,8 @@ class ModelRenderer:
r, g, b = (int(round(x * 255)) for x in rgb) r, g, b = (int(round(x * 255)) for x in rgb)
return f"#{r:02x}{g:02x}{b:02x}" return f"#{r:02x}{g:02x}{b:02x}"
_NODE_LUT: ClassVar[list[str]] = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected _NODE_LUT = ["#d9d9d9", "#00ffff"] # gray normal, cyan selected
_FRAME_LUT: ClassVar[list[str]] = ["#338cd9", "#00ffff"] # blue normal, cyan selected _FRAME_LUT = ["#338cd9", "#00ffff"] # blue normal, cyan selected
def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None: def __init__(self, plotter: Any, style: RenderStyle | None = None) -> None:
self._plotter = plotter self._plotter = plotter
@ -220,8 +219,10 @@ class ModelRenderer:
# plan view at Z=3 doesn't see the Z=0 grid cluttering the view. # plan view at Z=3 doesn't see the Z=0 grid cluttering the view.
self._working_plane: tuple[str, float] | None = None self._working_plane: tuple[str, float] | None = None
with contextlib.suppress(Exception): try:
self._plotter.enable_anti_aliasing("ssaa") self._plotter.enable_anti_aliasing("ssaa")
except Exception:
pass
# ── public API ─────────────────────────────────────────────────── # ── public API ───────────────────────────────────────────────────
def render(self, project: Project | None) -> None: def render(self, project: Project | None) -> None:
@ -328,8 +329,10 @@ class ModelRenderer:
""" """
# Remove any previous marker. # Remove any previous marker.
if self._hover_actor is not None: if self._hover_actor is not None:
with contextlib.suppress(Exception): try:
self._plotter.remove_actor(self._hover_actor, render=False) self._plotter.remove_actor(self._hover_actor, render=False)
except Exception:
pass
self._hover_actor = None self._hover_actor = None
if world_point is None: if world_point is None:
@ -373,8 +376,10 @@ class ModelRenderer:
sphere = pv.Sphere(radius=radius, theta_resolution=8, phi_resolution=8) sphere = pv.Sphere(radius=radius, theta_resolution=8, phi_resolution=8)
glyph = self._node_pd.glyph(geom=sphere, scale=False, orient=False) glyph = self._node_pd.glyph(geom=sphere, scale=False, orient=False)
if self._node_actor is not None: if self._node_actor is not None:
with contextlib.suppress(Exception): try:
self._plotter.remove_actor(self._node_actor, render=False) self._plotter.remove_actor(self._node_actor, render=False)
except Exception:
pass
self._node_glyph = glyph self._node_glyph = glyph
self._node_actor = self._plotter.add_mesh( self._node_actor = self._plotter.add_mesh(
glyph, glyph,
@ -479,17 +484,10 @@ class ModelRenderer:
plane_axis = axis_idx plane_axis = axis_idx
plane_offset_local = plane_off - cs_shift plane_offset_local = plane_off - cs_shift
# Bind the per-iteration plane state as defaults so the closure def _on_active_plane(local_pt: tuple[float, float, float]) -> bool:
# cannot read a later iteration's values (B023); the helper is if plane_axis is None:
# only ever called from within this loop body.
def _on_active_plane(
local_pt: tuple[float, float, float],
_axis: int | None = plane_axis,
_offset: float | None = plane_offset_local,
) -> bool:
if _axis is None or _offset is None:
return True return 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 # Collect active + dim segments separately so they get their
# own polydata + actor (different opacity / color). # own polydata + actor (different opacity / color).
@ -498,21 +496,13 @@ class ModelRenderer:
dim_pts: list[tuple[float, float, float]] = [] dim_pts: list[tuple[float, float, float]] = []
dim_cells: list[int] = [] dim_cells: list[int] = []
def add_seg( def add_seg(p1: tuple[float, float, float], p2: tuple[float, float, float]) -> None:
p1: tuple[float, float, float],
p2: tuple[float, float, float],
_active_pts: list[tuple[float, float, float]] = active_pts,
_active_cells: list[int] = active_cells,
_dim_pts: list[tuple[float, float, float]] = dim_pts,
_dim_cells: list[int] = dim_cells,
_cs: Any = cs,
) -> None:
on_active = _on_active_plane(p1) and _on_active_plane(p2) on_active = _on_active_plane(p1) and _on_active_plane(p2)
bucket_pts = _active_pts if on_active else _dim_pts bucket_pts = active_pts if on_active else dim_pts
bucket_cells = _active_cells if on_active else _dim_cells bucket_cells = active_cells if on_active else dim_cells
i = len(bucket_pts) i = len(bucket_pts)
bucket_pts.append(_cs.coord.local_to_world(p1)) 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(p2))
bucket_cells.extend([2, i, i + 1]) bucket_cells.extend([2, i, i + 1])
z_planes = zs if zs else [0.0] z_planes = zs if zs else [0.0]
@ -624,7 +614,7 @@ class ModelRenderer:
continue continue
# By design: area-only / thickness-only / material-only # By design: area-only / thickness-only / material-only
# elements carry no section. # elements carry no section.
if isinstance(el, TrussElement | CorotTrussElement | QuadElement | ZeroLengthElement): if isinstance(el, (TrussElement, CorotTrussElement, QuadElement, ZeroLengthElement)):
continue continue
node_i = node_by_id.get(el.nodes[0]) node_i = node_by_id.get(el.nodes[0])
node_j = node_by_id.get(el.nodes[1]) node_j = node_by_id.get(el.nodes[1])
@ -675,16 +665,18 @@ class ModelRenderer:
diag = self._diag_of_points(self._node_original_points) diag = self._diag_of_points(self._node_original_points)
cap = max(diag * 0.08, 1e-6) cap = max(diag * 0.08, 1e-6)
for el in project.elements: for el in project.elements:
if isinstance(el, QuadElement | ZeroLengthElement | ZeroLengthSectionElement): if isinstance(el, (QuadElement, ZeroLengthElement, ZeroLengthSectionElement)):
continue continue
if not isinstance( if not isinstance(
el, el,
ElasticBeamColumn (
| DispBeamColumn ElasticBeamColumn,
| ForceBeamColumn DispBeamColumn,
| BeamWithHingesElement ForceBeamColumn,
| TrussElement BeamWithHingesElement,
| CorotTrussElement, TrussElement,
CorotTrussElement,
),
): ):
continue continue
node_i = node_by_id.get(el.nodes[0]) node_i = node_by_id.get(el.nodes[0])
@ -1102,11 +1094,15 @@ class ModelRenderer:
self._clear_label_actors() self._clear_label_actors()
for a in (self._node_actor, self._frame_actor): for a in (self._node_actor, self._frame_actor):
if a is not None: if a is not None:
with contextlib.suppress(Exception): try:
self._plotter.remove_actor(a, render=False) self._plotter.remove_actor(a, render=False)
except Exception:
pass
for a in self._aux_actors: for a in self._aux_actors:
with contextlib.suppress(Exception): try:
self._plotter.remove_actor(a, render=False) self._plotter.remove_actor(a, render=False)
except Exception:
pass
self._node_actor = None self._node_actor = None
self._frame_actor = None self._frame_actor = None
self._aux_actors.clear() self._aux_actors.clear()
@ -1124,8 +1120,10 @@ class ModelRenderer:
def _clear_label_actors(self) -> None: def _clear_label_actors(self) -> None:
for actor in (self._node_label_actor, self._element_label_actor): for actor in (self._node_label_actor, self._element_label_actor):
if actor is not None: if actor is not None:
with contextlib.suppress(Exception): try:
self._plotter.remove_actor(actor, render=False) self._plotter.remove_actor(actor, render=False)
except Exception:
pass
self._node_label_actor = None self._node_label_actor = None
self._element_label_actor = None self._element_label_actor = None

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_support import PRESETS, AssignSupportDialog
from otko.views.dialogs.assign_zls import AssignZeroLengthSectionDialog from otko.views.dialogs.assign_zls import AssignZeroLengthSectionDialog
from otko.views.dialogs.case_manager import AnalysisCaseManagerDialog from otko.views.dialogs.case_manager import AnalysisCaseManagerDialog
from otko.views.dialogs.combination_dialog import LoadCombinationManagerDialog
from otko.views.dialogs.coord_grid_systems import ( from otko.views.dialogs.coord_grid_systems import (
CoordinateGridSystemsDialog, CoordinateGridSystemsDialog,
CoordSystemDataDialog, CoordSystemDataDialog,
@ -61,7 +60,6 @@ __all__ = [
"DisplayOptionsDialog", "DisplayOptionsDialog",
"GridSystemDialog", "GridSystemDialog",
"LinearTimeSeriesDialog", "LinearTimeSeriesDialog",
"LoadCombinationManagerDialog",
"MaterialLibraryDialog", "MaterialLibraryDialog",
"MaterialTesterDialog", "MaterialTesterDialog",
"MirrorDialog", "MirrorDialog",

View file

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

View file

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

View file

@ -64,7 +64,9 @@ class AssignHingeDialog(QDialog):
def _build_ui(self, n_selected: int) -> None: def _build_ui(self, n_selected: int) -> None:
layout = QVBoxLayout(self) 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() prefill = self._selection_prefill()
if prefill is None and self._element_ids: if prefill is None and self._element_ids:
header += " <i>Selection is mixed — unit-scaled defaults shown.</i>" header += " <i>Selection is mixed — unit-scaled defaults shown.</i>"
@ -158,8 +160,7 @@ class AssignHingeDialog(QDialog):
if el is None: if el is None:
return None return None
if getattr(el, "type", None) == "BeamWithHinges": if getattr(el, "type", None) == "BeamWithHinges":
collected.append( collected.append({
{
"section_i_id": getattr(el, "section_i_id", None), "section_i_id": getattr(el, "section_i_id", None),
"section_j_id": getattr(el, "section_j_id", None), "section_j_id": getattr(el, "section_j_id", None),
"lp_i": getattr(el, "lp_i", None), "lp_i": getattr(el, "lp_i", None),
@ -170,8 +171,7 @@ class AssignHingeDialog(QDialog):
"Iy": getattr(el, "Iy", None), "Iy": getattr(el, "Iy", None),
"G": getattr(el, "G", None), "G": getattr(el, "G", None),
"J": getattr(el, "J", None), "J": getattr(el, "J", None),
} })
)
continue continue
sec_id = getattr(el, "section_id", None) sec_id = getattr(el, "section_id", None)
if sec_id is None: if sec_id is None:
@ -185,8 +185,7 @@ class AssignHingeDialog(QDialog):
# prefill the hinge pickers with the section itself. # prefill the hinge pickers with the section itself.
collected.append({"section_i_id": sec_id, "section_j_id": sec_id}) collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
continue continue
collected.append( collected.append({
{
"section_i_id": sec_id, "section_i_id": sec_id,
"section_j_id": sec_id, "section_j_id": sec_id,
"E": getattr(sec, "E", None), "E": getattr(sec, "E", None),
@ -195,12 +194,12 @@ class AssignHingeDialog(QDialog):
"Iy": getattr(sec, "Iy", None), "Iy": getattr(sec, "Iy", None),
"G": getattr(sec, "G", None), "G": getattr(sec, "G", None),
"J": getattr(sec, "J", None), "J": getattr(sec, "J", None),
} })
)
if not collected: if not collected:
return None return None
merged: dict = {} 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} vals = {c.get(key) for c in collected if c.get(key) is not None}
if len(vals) == 1: if len(vals) == 1:
merged[key] = next(iter(vals)) merged[key] = next(iter(vals))

View file

@ -8,6 +8,7 @@ from PySide6.QtWidgets import (
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox, QDoubleSpinBox,
QFormLayout, QFormLayout,
QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
QVBoxLayout, QVBoxLayout,
@ -79,7 +80,9 @@ class AssignLoadDialog(QDialog):
pf.addRow("Active:", self._active_pattern_label) pf.addRow("Active:", self._active_pattern_label)
self._new_name_edit = QLineEdit("Pattern") 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) pf.addRow("New name:", self._new_name_edit)
# TimeSeries type selector — only used when creating a new # 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 from __future__ import annotations
import contextlib
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QCheckBox, QCheckBox,
QDialog, QDialog,
@ -58,22 +56,16 @@ class AssignMassesDialog(QDialog):
cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
form = QFormLayout() form = QFormLayout()
self._mx = self._spin(cur[0]) self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx)
form.addRow("Translation X:", self._mx) self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my)
self._my = self._spin(cur[1])
form.addRow("Translation Y:", self._my)
if self._ndf >= 3: if self._ndf >= 3:
self._mz = self._spin(cur[2]) self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz)
form.addRow("Translation Z:", self._mz)
else: else:
self._mz = self._spin(cur[2]) self._mz = self._spin(cur[2])
if self._ndf == 6: if self._ndf == 6:
self._mxx = self._spin(cur[3]) self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx)
form.addRow("Rotation X (Ixx):", self._mxx) self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy)
self._myy = self._spin(cur[4]) self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz)
form.addRow("Rotation Y (Iyy):", self._myy)
self._mzz = self._spin(cur[5])
form.addRow("Rotation Z (Izz):", self._mzz)
else: else:
self._mxx = self._spin(cur[3]) self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4]) self._myy = self._spin(cur[4])
@ -85,7 +77,8 @@ class AssignMassesDialog(QDialog):
root.addWidget(self._xy_link) root.addWidget(self._xy_link)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -105,16 +98,14 @@ class AssignMassesDialog(QDialog):
self._my.setValue(self._mx.value()) self._my.setValue(self._mx.value())
self._mx.valueChanged.connect(self._my.setValue) self._mx.valueChanged.connect(self._my.setValue)
else: else:
with contextlib.suppress(RuntimeError, TypeError): try:
self._mx.valueChanged.disconnect(self._my.setValue) self._mx.valueChanged.disconnect(self._my.setValue)
except (RuntimeError, TypeError):
pass
def mass_vector(self) -> tuple[float, float, float, float, float, float]: def mass_vector(self) -> tuple[float, float, float, float, float, float]:
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz).""" """Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
return ( return (
self._mx.value(), self._mx.value(), self._my.value(), self._mz.value(),
self._my.value(), self._mxx.value(), self._myy.value(), self._mzz.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())) layout.addWidget(QLabel(self._current_note()))
if not self._sections: if not self._sections:
layout.addWidget( layout.addWidget(QLabel(
QLabel("<i>No sections defined. Open Define → Section Library first.</i>") "<i>No sections defined. Open Define → Section Library first.</i>"
) ))
self._combo = QComboBox() self._combo = QComboBox()
for s in self._sections: for s in self._sections:
@ -115,15 +115,15 @@ class AssignMaterialDialog(QDialog):
def _build_ui(self, n_elements: int) -> None: def _build_ui(self, n_elements: int) -> None:
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget( layout.addWidget(QLabel(
QLabel(f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s).") f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s)."
) ))
layout.addWidget(QLabel(self._current_note())) layout.addWidget(QLabel(self._current_note()))
if not self._materials: if not self._materials:
layout.addWidget( layout.addWidget(QLabel(
QLabel("<i>No materials defined. Open Define → Material Library first.</i>") "<i>No materials defined. Open Define → Material Library first.</i>"
) ))
self._combo = QComboBox() self._combo = QComboBox()
for m in self._materials: for m in self._materials:

View file

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

View file

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

View file

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

View file

@ -4,7 +4,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pydantic import ValidationError
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@ -20,6 +19,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from pydantic import ValidationError
from otko.commands import ( from otko.commands import (
AddAnalysisCasesCommand, AddAnalysisCasesCommand,
@ -27,14 +27,10 @@ from otko.commands import (
UpdateAnalysisCaseCommand, UpdateAnalysisCaseCommand,
) )
from otko.core import ( from otko.core import (
ModalCase, ModalCase, PushoverCase, ResponseSpectrumCase, StaticCase, TransientCase,
PushoverCase,
ResponseSpectrumCase,
StaticCase,
TransientCase,
) )
from otko.viewmodels import ProjectViewModel 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: if TYPE_CHECKING:
from otko.core import Project 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) return ModalCase(id=cid, name="Modal", n_modes=3)
if kind == "Transient": if kind == "Transient":
return TransientCase( return TransientCase(
id=cid, id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)],
name="Transient", dt=0.01, n_steps=1000,
pattern_ids=[_first_pattern_id(project)],
dt=0.01,
n_steps=1000,
) )
if kind == "Pushover": if kind == "Pushover":
if not project.nodes: if not project.nodes:
raise ValueError("Define at least one node before adding a Pushover case.") raise ValueError("Define at least one node before adding a Pushover case.")
return PushoverCase( return PushoverCase(
id=cid, id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)],
name="Pushover", control_node=project.nodes[0].id, control_dof=1,
pattern_ids=[_first_pattern_id(project)], target_disp=0.1, step_size=0.001,
control_node=project.nodes[0].id,
control_dof=1,
target_disp=0.1,
step_size=0.001,
) )
if kind == "ResponseSpectrum": if kind == "ResponseSpectrum":
modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)] 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.") raise ValueError("Add a Modal case before adding a ResponseSpectrum case.")
if not project.spectra: if not project.spectra:
raise ValueError( 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( return ResponseSpectrumCase(
id=cid, id=cid, name="ResponseSpectrum",
name="ResponseSpectrum", modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id,
modal_case_id=modal_ids[0], direction=1, combination="SRSS",
spectrum_id=project.spectra[0].id,
direction=1,
combination="SRSS",
) )
raise ValueError(f"Unknown analysis type: {kind}.") raise ValueError(f"Unknown analysis type: {kind}.")
@ -163,7 +150,7 @@ class AnalysisCaseManagerDialog(QDialog):
if self._list.currentItem() is not None: if self._list.currentItem() is not None:
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole) selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
self._list.clear() 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}]" label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]"
item = QListWidgetItem(label) item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, c.id) item.setData(Qt.ItemDataRole.UserRole, c.id)
@ -214,31 +201,22 @@ class AnalysisCaseManagerDialog(QDialog):
# patterns). Do NOT fall through to the general type picker — # patterns). Do NOT fall through to the general type picker —
# Static/Transient/Pushover would get dangling pattern refs. # Static/Transient/Pushover would get dangling pattern refs.
QMessageBox.information( QMessageBox.information(
self, self, "No patterns",
"No patterns",
"No load patterns defined yet — only a Modal case can be " "No load patterns defined yet — only a Modal case can be "
"added (Modal works without patterns). Define a pattern " "added (Modal works without patterns). Define a pattern "
"first for Static, Transient, or Pushover cases.", "first for Static, Transient, or Pushover cases.",
) )
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, self, "Add analysis case", "Type:",
"Add analysis case", ["Modal"], current=0, editable=False,
"Type:",
["Modal"],
current=0,
editable=False,
) )
if not ok: if not ok:
return return
self._create_case_of_kind("Modal") self._create_case_of_kind("Modal")
return return
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, self, "Add analysis case", "Type:",
"Add analysis case", list(_DEFAULTS.keys()), current=0, editable=False,
"Type:",
list(_DEFAULTS.keys()),
current=0,
editable=False,
) )
if not ok: if not ok:
return 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: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle( 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._is_global = is_global
self._existing = existing self._existing = existing
@ -87,33 +88,28 @@ class CoordSystemDataDialog(QDialog):
loc_box = QGroupBox("Location and Orientation (relative to Global)") loc_box = QGroupBox("Location and Orientation (relative to Global)")
loc_form = QFormLayout(loc_box) loc_form = QFormLayout(loc_box)
self._ox = self._spin() self._ox = self._spin(); self._oy = self._spin(); self._oz = self._spin()
self._oy = self._spin()
self._oz = self._spin()
origin_row = QHBoxLayout() origin_row = QHBoxLayout()
for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)): for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)):
origin_row.addWidget(QLabel(f"{label}:")) origin_row.addWidget(QLabel(f"{label}:"))
origin_row.addWidget(w) origin_row.addWidget(w)
origin_wrap = QWidget() origin_wrap = QWidget(); origin_wrap.setLayout(origin_row)
origin_wrap.setLayout(origin_row)
loc_form.addRow("Origin:", origin_wrap) loc_form.addRow("Origin:", origin_wrap)
self._rx = self._rot_spin() self._rx = self._rot_spin(); self._ry = self._rot_spin(); self._rz = self._rot_spin()
self._ry = self._rot_spin()
self._rz = self._rot_spin()
rot_row = QHBoxLayout() rot_row = QHBoxLayout()
for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)): 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(QLabel(f"{label}:"))
rot_row.addWidget(w) rot_row.addWidget(w)
rot_wrap = QWidget() rot_wrap = QWidget(); rot_wrap.setLayout(rot_row)
rot_wrap.setLayout(rot_row)
loc_form.addRow("Rotation (deg):", rot_wrap) loc_form.addRow("Rotation (deg):", rot_wrap)
if self._is_global: if self._is_global:
for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz): for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz):
w.setEnabled(False) w.setEnabled(False)
loc_box.setToolTip( 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) layout.addWidget(loc_box)
@ -164,7 +160,8 @@ class CoordSystemDataDialog(QDialog):
# ── Buttons ────────────────────────────────────────────── # ── Buttons ──────────────────────────────────────────────
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self._on_accept) buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -192,13 +189,9 @@ class CoordSystemDataDialog(QDialog):
def _load_existing(self, cs: CoordinateGridSystem) -> None: def _load_existing(self, cs: CoordinateGridSystem) -> None:
self._name_edit.setText(cs.name) self._name_edit.setText(cs.name)
ox, oy, oz = cs.coord.origin ox, oy, oz = cs.coord.origin
self._ox.setValue(ox) self._ox.setValue(ox); self._oy.setValue(oy); self._oz.setValue(oz)
self._oy.setValue(oy)
self._oz.setValue(oz)
rx, ry, rz = cs.coord.rotation_deg rx, ry, rz = cs.coord.rotation_deg
self._rx.setValue(rx) self._rx.setValue(rx); self._ry.setValue(ry); self._rz.setValue(rz)
self._ry.setValue(ry)
self._rz.setValue(rz)
self._x_edit.setText(_spacings_text(cs.grid.x_lines)) self._x_edit.setText(_spacings_text(cs.grid.x_lines))
self._y_edit.setText(_spacings_text(cs.grid.y_lines)) self._y_edit.setText(_spacings_text(cs.grid.y_lines))
self._z_edit.setText(_spacings_text(cs.grid.z_lines)) self._z_edit.setText(_spacings_text(cs.grid.z_lines))
@ -252,7 +245,9 @@ class CoordinateGridSystemsDialog(QDialog):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Coordinate/Grid Systems") self.setWindowTitle("Coordinate/Grid Systems")
self.resize(600, 420) 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._build_ui()
self._refresh_list() self._refresh_list()
@ -295,7 +290,8 @@ class CoordinateGridSystemsDialog(QDialog):
right.addStretch(1) right.addStretch(1)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -336,7 +332,9 @@ class CoordinateGridSystemsDialog(QDialog):
self._btn_modify.setEnabled(cs is not None) self._btn_modify.setEnabled(cs is not None)
self._cb_general.blockSignals(True) self._cb_general.blockSignals(True)
self._cb_general.setChecked(cs.grid.is_general if cs is not None else False) 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) self._cb_general.blockSignals(False)
# ── actions ────────────────────────────────────────────────── # ── actions ──────────────────────────────────────────────────
@ -353,17 +351,14 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = DefineGridSystemDataDialog(parent=self) dlg = DefineGridSystemDataDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
new_sys = dlg.system() new_sys = dlg.system()
if any(s.name == new_sys.name for s in self._systems): if any(s.name == new_sys.name for s in self._systems):
new_sys = new_sys.model_copy( new_sys = new_sys.model_copy(update={
update={
"name": self._unique_name(new_sys.name), "name": self._unique_name(new_sys.name),
} })
)
self._systems.append(new_sys) self._systems.append(new_sys)
self._refresh_list(select_name=new_sys.name) self._refresh_list(select_name=new_sys.name)
@ -385,11 +380,8 @@ class CoordinateGridSystemsDialog(QDialog):
from otko.views.dialogs.define_grid_data import ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = DefineGridSystemDataDialog( dlg = DefineGridSystemDataDialog(
existing=cs, existing=cs, is_global=cs.is_global(), parent=self,
is_global=cs.is_global(),
parent=self,
) )
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@ -400,11 +392,9 @@ class CoordinateGridSystemsDialog(QDialog):
# Enforce unique names against siblings. # Enforce unique names against siblings.
others = [s for s in self._systems if s is not cs] others = [s for s in self._systems if s is not cs]
if any(s.name == updated.name for s in others): if any(s.name == updated.name for s in others):
updated = updated.model_copy( updated = updated.model_copy(update={
update={
"name": self._unique_name(updated.name), "name": self._unique_name(updated.name),
} })
)
row = self._list.currentRow() row = self._list.currentRow()
self._systems[row] = updated self._systems[row] = updated
self._refresh_list(select_name=updated.name) self._refresh_list(select_name=updated.name)
@ -424,8 +414,7 @@ class CoordinateGridSystemsDialog(QDialog):
if cs.grid.is_general and not checked: if cs.grid.is_general and not checked:
# SAP2000: once converted to General, can't convert back. # SAP2000: once converted to General, can't convert back.
QMessageBox.information( QMessageBox.information(
self, self, "Convert to General",
"Convert to General",
"Once a system is converted to General, it cannot be " "Once a system is converted to General, it cannot be "
"converted back to a regular Cartesian system.", "converted back to a regular Cartesian system.",
) )

View file

@ -18,8 +18,6 @@ Top-level controls:
from __future__ import annotations from __future__ import annotations
from typing import ClassVar
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QColor from PySide6.QtGui import QColor
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -29,6 +27,7 @@ from PySide6.QtWidgets import (
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox,
QFormLayout, QFormLayout,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
@ -53,6 +52,7 @@ from otko.core.geometry import (
GridSystem, GridSystem,
) )
_LINE_TYPE_CHOICES = ["Primary", "Secondary"] _LINE_TYPE_CHOICES = ["Primary", "Secondary"]
_BUBBLE_LOC_CHOICES = ["Start", "End"] _BUBBLE_LOC_CHOICES = ["Start", "End"]
@ -60,14 +60,7 @@ _BUBBLE_LOC_CHOICES = ["Start", "End"]
class _AxisGridTable(QWidget): class _AxisGridTable(QWidget):
"""A single X/Y/Z spreadsheet — one row per GridLine.""" """A single X/Y/Z spreadsheet — one row per GridLine."""
COLUMNS: ClassVar[list[str]] = [ COLUMNS = ["Grid ID", "Ordinate", "Line Type", "Visibility", "Bubble Loc", "Color"]
"Grid ID",
"Ordinate",
"Line Type",
"Visibility",
"Bubble Loc",
"Color",
]
def __init__(self, axis: str, parent: QWidget | None = None) -> None: def __init__(self, axis: str, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -176,28 +169,23 @@ class _AxisGridTable(QWidget):
visible_widget = self._table.cellWidget(row, 3) visible_widget = self._table.cellWidget(row, 3)
bubble_widget = self._table.cellWidget(row, 4) bubble_widget = self._table.cellWidget(row, 4)
color_item = self._table.item(row, 5) color_item = self._table.item(row, 5)
out.append( out.append(GridLine(
GridLine(
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"), id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
ordinate=ords[row] if row < len(ords) else 0.0, ordinate=ords[row] if row < len(ords) else 0.0,
line_type=( line_type=(
line_type_widget.currentText() # type: ignore[union-attr] line_type_widget.currentText() # type: ignore[union-attr]
if isinstance(line_type_widget, QComboBox) if isinstance(line_type_widget, QComboBox) else "Primary"
else "Primary"
), ),
visible=( visible=(
visible_widget.isChecked() # type: ignore[union-attr] visible_widget.isChecked() # type: ignore[union-attr]
if isinstance(visible_widget, QCheckBox) if isinstance(visible_widget, QCheckBox) else True
else True
), ),
bubble_loc=( bubble_loc=(
bubble_widget.currentText() # type: ignore[union-attr] bubble_widget.currentText() # type: ignore[union-attr]
if isinstance(bubble_widget, QComboBox) if isinstance(bubble_widget, QComboBox) else "End"
else "End"
), ),
color=(color_item.text() if color_item else "#808080"), color=(color_item.text() if color_item else "#808080"),
) ))
)
return out return out
# ── row ops ─────────────────────────────────────────────────── # ── row ops ───────────────────────────────────────────────────
@ -230,8 +218,7 @@ class _AxisGridTable(QWidget):
vis = QCheckBox() vis = QCheckBox()
vis.setChecked(ln.visible) vis.setChecked(ln.visible)
vis_wrap = QWidget() vis_wrap = QWidget()
lay = QHBoxLayout(vis_wrap) lay = QHBoxLayout(vis_wrap); lay.setContentsMargins(0, 0, 0, 0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(vis) lay.addWidget(vis)
lay.setAlignment(Qt.AlignmentFlag.AlignCenter) lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
# We keep the QCheckBox directly; the wrap is optional visual # We keep the QCheckBox directly; the wrap is optional visual
@ -291,7 +278,8 @@ class DefineGridSystemDataDialog(QDialog):
) -> None: ) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle( 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.resize(780, 620)
self._is_global = is_global self._is_global = is_global
@ -340,7 +328,9 @@ class DefineGridSystemDataDialog(QDialog):
self._btn_locate.clicked.connect(self._on_locate_origin) self._btn_locate.clicked.connect(self._on_locate_origin)
if self._is_global: if self._is_global:
self._btn_locate.setEnabled(False) 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) head.addWidget(self._btn_locate)
root.addLayout(head) root.addLayout(head)
@ -385,7 +375,8 @@ class DefineGridSystemDataDialog(QDialog):
bottom.addWidget(self._btn_quick) bottom.addWidget(self._btn_quick)
bottom.addStretch(1) bottom.addStretch(1)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self._on_accept) buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -397,7 +388,8 @@ class DefineGridSystemDataDialog(QDialog):
ox, oy, oz = self._origin ox, oy, oz = self._origin
rx, ry, rz = self._rotation_deg rx, ry, rz = self._rotation_deg
self._origin_label.setText( 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: def _load_existing(self, cs: CoordinateGridSystem) -> None:
@ -423,7 +415,6 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.locate_origin import ( from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog, CoordSystemLocationOrientationDialog,
) )
dlg = CoordSystemLocationOrientationDialog( dlg = CoordSystemLocationOrientationDialog(
origin=self._origin, origin=self._origin,
rotation_deg=self._rotation_deg, rotation_deg=self._rotation_deg,
@ -438,13 +429,11 @@ class DefineGridSystemDataDialog(QDialog):
from otko.views.dialogs.quick_grid_lines import ( from otko.views.dialogs.quick_grid_lines import (
QuickGridLinesDialog, QuickGridLinesDialog,
) )
dlg = QuickGridLinesDialog(parent=self) dlg = QuickGridLinesDialog(parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
xs, ys, zs = dlg.ordinates() xs, ys, zs = dlg.ordinates()
from otko.core.geometry import make_grid_lines from otko.core.geometry import make_grid_lines
# Revert to ordinate display mode before replacing rows. # Revert to ordinate display mode before replacing rows.
self._rb_ordinates.setChecked(True) self._rb_ordinates.setChecked(True)
self._tab_x.load_lines(make_grid_lines("X", xs)) self._tab_x.load_lines(make_grid_lines("X", xs))
@ -476,8 +465,7 @@ class DefineGridSystemDataDialog(QDialog):
return CoordinateGridSystem( return CoordinateGridSystem(
name=self._name_edit.text().strip(), name=self._name_edit.text().strip(),
coord=CoordinateSystem( coord=CoordinateSystem(
origin=self._origin, origin=self._origin, rotation_deg=self._rotation_deg,
rotation_deg=self._rotation_deg,
), ),
grid=grid, grid=grid,
) )

View file

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

View file

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

View file

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

View file

@ -33,16 +33,10 @@ from otko.core import (
# ─────────────────────────── helpers ─────────────────────────── # ─────────────────────────── helpers ───────────────────────────
def _spin( def _spin(default: float = 0.0, *, decimals: int = 6,
default: float = 0.0, minimum: float = -1e15, maximum: float = 1e15,
*, step: float = 1.0) -> QDoubleSpinBox:
decimals: int = 6,
minimum: float = -1e15,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
from PySide6.QtCore import QLocale from PySide6.QtCore import QLocale
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
# Force C locale so "." is always the decimal separator. # Force C locale so "." is always the decimal separator.
sb.setLocale(QLocale(QLocale.Language.C)) sb.setLocale(QLocale(QLocale.Language.C))
@ -116,11 +110,8 @@ class Steel01Form(MaterialFormBase):
def _read_specific(self, mid: int) -> Steel01: def _read_specific(self, mid: int) -> Steel01:
return Steel01( return Steel01(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
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._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._cR1 = _spin(0.925, decimals=4, step=0.01)
self._cR2 = _spin(0.15, decimals=4, step=0.01) self._cR2 = _spin(0.15, decimals=4, step=0.01)
for label, w in ( for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b),
("Fy:", self._fy), ("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)):
("E0:", self._e0),
("b:", self._b),
("R0:", self._r0),
("cR1:", self._cR1),
("cR2:", self._cR2),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Steel02) -> None: def _populate_specific(self, m: Steel02) -> None:
self._fy.setValue(m.Fy) self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b)
self._e0.setValue(m.E0) self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2)
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: def _read_specific(self, mid: int) -> Steel02:
return Steel02( return Steel02(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
Fy=self._fy.value(), R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.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._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._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) self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
for label, w in ( for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
("f'c (-):", self._fpc), ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)):
("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu),
("ε_U (-):", self._epsU),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete01) -> None: def _populate_specific(self, m: Concrete01) -> None:
self._fpc.setValue(m.fpc) self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
self._epsc0.setValue(m.epsc0) self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
self._fpcu.setValue(m.fpcu)
self._epsU.setValue(m.epsU)
def _read_specific(self, mid: int) -> Concrete01: def _read_specific(self, mid: int) -> Concrete01:
return Concrete01( return Concrete01(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), fpc=self._fpc.value(), epsc0=self._epsc0.value(),
fpc=self._fpc.value(), fpcu=self._fpcu.value(), epsU=self._epsU.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._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._ft = _spin(3e6, step=1.0, minimum=1e-9)
self._ets = _spin(2e9, step=1.0, minimum=1e-9) self._ets = _spin(2e9, step=1.0, minimum=1e-9)
for label, w in ( for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
("f'c (-):", self._fpc), ("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU),
("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu),
("ε_U (-):", self._epsU),
("λ (unload ratio):", self._lambda), ("λ (unload ratio):", self._lambda),
("ft (tensile):", self._ft), ("ft (tensile):", self._ft), ("Ets (soften):", self._ets)):
("Ets (soften):", self._ets),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
def _populate_specific(self, m: Concrete02) -> None: def _populate_specific(self, m: Concrete02) -> None:
self._fpc.setValue(m.fpc) self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
self._epsc0.setValue(m.epsc0) self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
self._fpcu.setValue(m.fpcu) self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets)
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: def _read_specific(self, mid: int) -> Concrete02:
return Concrete02( return Concrete02(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), fpc=self._fpc.value(), epsc0=self._epsc0.value(),
fpc=self._fpc.value(), fpcu=self._fpcu.value(), epsU=self._epsU.value(),
epsc0=self._epsc0.value(), ft=self._ft.value(), Ets=self._ets.value(),
fpcu=self._fpcu.value(),
epsU=self._epsU.value(),
ft=self._ft.value(),
Ets=self._ets.value(),
**{"lambda": self._lambda.value()}, **{"lambda": self._lambda.value()},
) )
@ -289,12 +243,9 @@ class Concrete04Form(MaterialFormBase):
fct_val = self._fct.value() fct_val = self._fct.value()
et_val = self._et.value() et_val = self._et.value()
return Concrete04( return Concrete04(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), fpc=self._fpc.value(), epsc0=self._epsc0.value(),
fpc=self._fpc.value(), epscu=self._epscu.value(), Ec=self._Ec.value(),
epsc0=self._epsc0.value(),
epscu=self._epscu.value(),
Ec=self._Ec.value(),
fct=fct_val if fct_val > 0.0 else None, fct=fct_val if fct_val > 0.0 else None,
et=et_val if et_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) self._layout.addRow("η (damping):", self._eta)
def _populate_specific(self, m: ElasticUniaxial) -> None: def _populate_specific(self, m: ElasticUniaxial) -> None:
self._e.setValue(m.E) self._e.setValue(m.E); self._eta.setValue(m.eta)
self._eta.setValue(m.eta)
def _read_specific(self, mid: int) -> ElasticUniaxial: def _read_specific(self, mid: int) -> ElasticUniaxial:
return ElasticUniaxial( return ElasticUniaxial(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), E=self._e.value(), eta=self._eta.value(),
E=self._e.value(),
eta=self._eta.value(),
) )
@ -338,17 +286,12 @@ class ElasticIsotropicForm(MaterialFormBase):
self._layout.addRow("ρ (density):", self._rho) self._layout.addRow("ρ (density):", self._rho)
def _populate_specific(self, m: ElasticIsotropic) -> None: def _populate_specific(self, m: ElasticIsotropic) -> None:
self._e.setValue(m.E) self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho)
self._nu.setValue(m.nu)
self._rho.setValue(m.rho)
def _read_specific(self, mid: int) -> ElasticIsotropic: def _read_specific(self, mid: int) -> ElasticIsotropic:
return ElasticIsotropic( return ElasticIsotropic(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(),
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) self._layout.addRow("ε_y (yield strain):", self._epsy)
def _populate_specific(self, m: ElasticPP) -> None: def _populate_specific(self, m: ElasticPP) -> None:
self._e.setValue(m.E) self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos)
self._epsy.setValue(m.epsy_pos)
def _read_specific(self, mid: int) -> ElasticPP: def _read_specific(self, mid: int) -> ElasticPP:
return ElasticPP( return ElasticPP(
id=mid, id=mid, name=self._name_edit.text(),
name=self._name_edit.text(), E=self._e.value(), epsy_pos=self._epsy.value(),
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 __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QHBoxLayout, QHBoxLayout,
@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from pydantic import ValidationError
from otko.commands import ( from otko.commands import (
AddMaterialsCommand, AddMaterialsCommand,
@ -149,12 +150,7 @@ class MaterialLibraryDialog(QDialog):
return return
kinds = list(FORM_REGISTRY.keys()) kinds = list(FORM_REGISTRY.keys())
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, self, "Add material", "Type:", kinds, current=0, editable=False,
"Add material",
"Type:",
kinds,
current=0,
editable=False,
) )
if not ok: if not ok:
return return
@ -183,10 +179,9 @@ class MaterialLibraryDialog(QDialog):
if material is None: if material is None:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, self, "Delete material",
"Delete material",
f"Delete material #{material.id} ({material.type})?\n" 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: if reply != QMessageBox.StandardButton.Yes:
return return

View file

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

View file

@ -44,13 +44,11 @@ class PathTimeSeriesDialog(QDialog):
def _build_ui(self) -> None: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget( root.addWidget(QLabel(
QLabel(
"<b>Path TimeSeries</b> — tabulated values sampled at a " "<b>Path TimeSeries</b> — tabulated values sampled at a "
"uniform time step. Used by UniformExcitation (ground " "uniform time step. Used by UniformExcitation (ground "
"motion) and by PlainLoadPattern scaled forces." "motion) and by PlainLoadPattern scaled forces."
) ))
)
form = QFormLayout() form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion") self._name_edit = QLineEdit("GroundMotion")
@ -86,7 +84,9 @@ class PathTimeSeriesDialog(QDialog):
btn_row.addWidget(self._btn_plain) btn_row.addWidget(self._btn_plain)
root.addLayout(btn_row) 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.setWordWrap(True)
self._status.setStyleSheet("color: #666;") self._status.setStyleSheet("color: #666;")
root.addWidget(self._status) root.addWidget(self._status)
@ -102,7 +102,8 @@ class PathTimeSeriesDialog(QDialog):
root.addWidget(self._preview, 1) root.addWidget(self._preview, 1)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self._on_accept) buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -111,16 +112,14 @@ class PathTimeSeriesDialog(QDialog):
# ── file-import slots ─────────────────────────────────────────── # ── file-import slots ───────────────────────────────────────────
def _on_import_peer(self) -> None: def _on_import_peer(self) -> None:
fname, _ = QFileDialog.getOpenFileName( fname, _ = QFileDialog.getOpenFileName(
self, self, "Import PEER record",
"Import PEER record", "", "PEER records (*.at2 *.AT2);;All files (*)",
"",
"PEER records (*.at2 *.AT2);;All files (*)",
) )
if not fname: if not fname:
return return
try: try:
dt, npts, vals = parse_peer_record(fname) 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)) QMessageBox.critical(self, "PEER import failed", str(exc))
return return
self._values = vals self._values = vals
@ -133,21 +132,20 @@ class PathTimeSeriesDialog(QDialog):
def _on_import_plain(self) -> None: def _on_import_plain(self) -> None:
fname, _ = QFileDialog.getOpenFileName( fname, _ = QFileDialog.getOpenFileName(
self, self, "Import plain values",
"Import plain values", "", "Text files (*.txt *.csv *.dat);;All files (*)",
"",
"Text files (*.txt *.csv *.dat);;All files (*)",
) )
if not fname: if not fname:
return return
try: try:
vals = parse_plain_values(fname) vals = parse_plain_values(fname)
except Exception as exc: except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Import failed", str(exc)) QMessageBox.critical(self, "Import failed", str(exc))
return return
self._values = vals self._values = vals
self._status.setText( 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() self._refresh_preview()
@ -169,9 +167,7 @@ class PathTimeSeriesDialog(QDialog):
def _on_accept(self) -> None: def _on_accept(self) -> None:
if not self._values: if not self._values:
QMessageBox.warning( QMessageBox.warning(
self, self, "No data", "Import a record first.",
"No data",
"Import a record first.",
) )
return return
self.accept() self.accept()

View file

@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QDoubleSpinBox, QDoubleSpinBox,
QFormLayout,
QGroupBox, QGroupBox,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
@ -30,56 +31,39 @@ class QuickGridLinesDialog(QDialog):
def _build_ui(self) -> None: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget( root.addWidget(QLabel(
QLabel(
"<b>Quick-define a regular (Cartesian) grid.</b><br>" "<b>Quick-define a regular (Cartesian) grid.</b><br>"
"Each axis: number of lines, equal spacing, first line coordinate." "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._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._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) self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
# Sensible defaults. # Sensible defaults.
self._x_n.setValue(3) self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0)
self._x_s.setValue(6.0) self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0)
self._x_f.setValue(0.0) self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_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( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
root.addWidget(buttons) root.addWidget(buttons)
def _axis_group( def _axis_group(
self, self, title: str, parent_layout: QVBoxLayout,
title: str,
parent_layout: QVBoxLayout,
) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]: ) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]:
box = QGroupBox(title) box = QGroupBox(title)
row = QHBoxLayout(box) row = QHBoxLayout(box)
n = QSpinBox() n = QSpinBox(); n.setRange(0, 200); n.setValue(3)
n.setRange(0, 200) s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4)
n.setValue(3) s.setSingleStep(0.5); s.setValue(1.0)
s = QDoubleSpinBox() f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4)
s.setRange(0.0, 1e6) f.setSingleStep(0.5); f.setValue(0.0)
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(QLabel("Number of lines:"))
row.addWidget(n) row.addWidget(n)
@ -92,10 +76,8 @@ class QuickGridLinesDialog(QDialog):
def ordinates(self) -> tuple[list[float], list[float], list[float]]: def ordinates(self) -> tuple[list[float], list[float], list[float]]:
"""Return (xs, ys, zs) — flat lists of ordinate values.""" """Return (xs, ys, zs) — flat lists of ordinate values."""
def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]: def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]:
return [f.value() + i * s.value() for i in range(n.value())] return [f.value() + i * s.value() for i in range(n.value())]
return ( return (
axis(self._x_n, self._x_s, self._x_f), axis(self._x_n, self._x_s, self._x_f),
axis(self._y_n, self._y_s, self._y_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> <b>Define → Add Uniform Excitation…</b> for seismic input.</p>
<h4>4&nbsp;— Draw the model</h4> <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>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) <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> 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: def _build_ui(self, n_nodes: int, n_elements: int) -> None:
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget( layout.addWidget(QLabel(
QLabel(f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).") f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)."
) ))
layout.addWidget( layout.addWidget(QLabel(
QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>") "<i>Only elements whose endpoints are both in the selection are copied.</i>"
) ))
form = QFormLayout() form = QFormLayout()

View file

@ -57,18 +57,6 @@ class RunAnalysisDialog(QDialog):
row.addWidget(self._case_combo, stretch=1) row.addWidget(self._case_combo, stretch=1)
layout.addLayout(row) 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. # Run-time damping overrides for transient cases only.
self._damping_box = QGroupBox("Rayleigh damping (transient only)") self._damping_box = QGroupBox("Rayleigh damping (transient only)")
form = QFormLayout(self._damping_box) form = QFormLayout(self._damping_box)
@ -91,14 +79,12 @@ class RunAnalysisDialog(QDialog):
self._mode1_damping.setSingleStep(0.01) self._mode1_damping.setSingleStep(0.01)
form.addRow("Mode-1 damping zeta:", self._mode1_damping) form.addRow("Mode-1 damping zeta:", self._mode1_damping)
form.addRow( form.addRow(QLabel(
QLabel(
"<i>Values are applied for this run only via a copy of the case — " "<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 " "the stored case is not modified. If mode-1 damping is "
"greater than zero, the runner computes betaK from the first " "greater than zero, the runner computes betaK from the first "
"mode after preload and overrides the manual betaK value.</i>", "mode after preload and overrides the manual betaK value.</i>",
) ))
)
layout.addWidget(self._damping_box) layout.addWidget(self._damping_box)
self._progress = QProgressBar() self._progress = QProgressBar()
@ -112,8 +98,7 @@ class RunAnalysisDialog(QDialog):
layout.addWidget(self._log, stretch=1) layout.addWidget(self._log, stretch=1)
self._buttons = QDialogButtonBox( self._buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Close, QDialogButtonBox.StandardButton.Close, parent=self,
parent=self,
) )
self._run_btn = QPushButton("Run") self._run_btn = QPushButton("Run")
self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole) self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole)
@ -121,7 +106,6 @@ class RunAnalysisDialog(QDialog):
def _wire(self) -> None: def _wire(self) -> None:
self._run_btn.clicked.connect(self._on_run) self._run_btn.clicked.connect(self._on_run)
self._eval_btn.clicked.connect(self._on_evaluate_combo)
self._buttons.rejected.connect(self.reject) self._buttons.rejected.connect(self.reject)
self._case_combo.currentIndexChanged.connect(self._on_case_changed) self._case_combo.currentIndexChanged.connect(self._on_case_changed)
@ -149,21 +133,15 @@ class RunAnalysisDialog(QDialog):
def _populate_cases(self) -> None: def _populate_cases(self) -> None:
self._case_combo.clear() self._case_combo.clear()
self._combo_combo.clear()
if self._vm.project is None: if self._vm.project is None:
self._run_btn.setEnabled(False) self._run_btn.setEnabled(False)
self._eval_btn.setEnabled(False)
self._damping_box.setVisible(False) self._damping_box.setVisible(False)
return return
for case in self._vm.project.analyses: for case in self._vm.project.analyses:
label = f"#{case.id} {case.name or '(unnamed)'} [{case.type}]" label = f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"
self._case_combo.addItem(label, userData=case.id) 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 has_cases = self._case_combo.count() > 0
self._run_btn.setEnabled(has_cases) self._run_btn.setEnabled(has_cases)
self._eval_btn.setEnabled(self._combo_combo.count() > 0)
if not has_cases: if not has_cases:
# No transient case can be selected, so the run-only damping # No transient case can be selected, so the run-only damping
# overrides are meaningless — hide them alongside the disabled Run. # overrides are meaningless — hide them alongside the disabled Run.
@ -179,13 +157,11 @@ class RunAnalysisDialog(QDialog):
return return
if case.type == "Transient": if case.type == "Transient":
mode1 = float(self._mode1_damping.value()) mode1 = float(self._mode1_damping.value())
case = case.model_copy( case = case.model_copy(update={
update={
"rayleigh_alpha_m": float(self._alpha_m.value()), "rayleigh_alpha_m": float(self._alpha_m.value()),
"rayleigh_beta_k": float(self._beta_k.value()), "rayleigh_beta_k": float(self._beta_k.value()),
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None, "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
} })
)
self._results = None self._results = None
self._log.clear() self._log.clear()
results_dir: Path | None = None results_dir: Path | None = None
@ -193,52 +169,9 @@ class RunAnalysisDialog(QDialog):
results_dir = self._vm.path.parent / f"{self._vm.path.stem}_results" results_dir = self._vm.path.parent / f"{self._vm.path.stem}_results"
try: try:
self._runner.run(self._vm.project, case, results_dir=results_dir) 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}") 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: def _on_started(self) -> None:
self._log.appendPlainText("--- Analysis started ---") self._log.appendPlainText("--- Analysis started ---")
@ -258,6 +191,4 @@ class RunAnalysisDialog(QDialog):
self._progress.setVisible(running) self._progress.setVisible(running)
# With zero cases Run must stay disabled even when idle. # With zero cases Run must stay disabled even when idle.
self._run_btn.setEnabled((not running) and self._case_combo.count() > 0) 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._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 from typing import Any
import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -44,26 +45,17 @@ from otko.services.section_properties import (
) )
_PATCH_COLORS = [ _PATCH_COLORS = [
"#4e79a7", "#4e79a7", "#f28e2b", "#e15759", "#76b7b2",
"#f28e2b", "#59a14f", "#edc948", "#b07aa1", "#ff9da7",
"#e15759",
"#76b7b2",
"#59a14f",
"#edc948",
"#b07aa1",
"#ff9da7",
] ]
class FiberSectionEditor(QDialog): class FiberSectionEditor(QDialog):
"""Modal dialog: build a FiberSection from patches + layers.""" """Modal dialog: build a FiberSection from patches + layers."""
def __init__( def __init__(self, material_ids: list[int],
self,
material_ids: list[int],
existing: FiberSection | None = None, existing: FiberSection | None = None,
parent: QWidget | None = None, parent: QWidget | None = None) -> None:
) -> None:
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Fiber Section Editor") self.setWindowTitle("Fiber Section Editor")
self.resize(1000, 650) self.resize(1000, 650)
@ -114,37 +106,25 @@ class FiberSectionEditor(QDialog):
pf.addRow("Material:", self._patch_mat) pf.addRow("Material:", self._patch_mat)
# Rect fields # Rect fields
self._rect_yi = self._spin(-0.15) self._rect_yi = self._spin(-0.15); self._rect_zi = 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_yj = self._spin(0.15) self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8)
self._rect_zj = self._spin(0.15)
self._rect_ny = self._ispin(8)
self._rect_nz = self._ispin(8)
self._rect_rows = [ self._rect_rows = [
("y_i:", self._rect_yi), ("y_i:", self._rect_yi), ("z_i:", self._rect_zi),
("z_i:", self._rect_zi), ("y_j:", self._rect_yj), ("z_j:", self._rect_zj),
("y_j:", self._rect_yj), ("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz),
("z_j:", self._rect_zj),
("n_fib_y:", self._rect_ny),
("n_fib_z:", self._rect_nz),
] ]
for label, widget in self._rect_rows: for label, widget in self._rect_rows:
pf.addRow(label, widget) pf.addRow(label, widget)
# Circ fields (initially hidden) # Circ fields (initially hidden)
self._circ_yc = self._spin(0.0) self._circ_yc = self._spin(0.0); self._circ_zc = 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_ri = self._spin(0.0) self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4)
self._circ_ro = self._spin(0.15)
self._circ_nc = self._ispin(16)
self._circ_nr = self._ispin(4)
self._circ_rows = [ self._circ_rows = [
("y_center:", self._circ_yc), ("y_center:", self._circ_yc), ("z_center:", self._circ_zc),
("z_center:", self._circ_zc), ("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro),
("r_inner:", self._circ_ri), ("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr),
("r_outer:", self._circ_ro),
("n_circ:", self._circ_nc),
("n_rad:", self._circ_nr),
] ]
for label, widget in self._circ_rows: for label, widget in self._circ_rows:
pf.addRow(label, widget) pf.addRow(label, widget)
@ -165,16 +145,12 @@ class FiberSectionEditor(QDialog):
lf.addRow("Material:", self._layer_mat) lf.addRow("Material:", self._layer_mat)
self._layer_nbars = self._ispin(4) self._layer_nbars = self._ispin(4)
self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12) self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12)
self._layer_ys = self._spin(-0.12) self._layer_ys = self._spin(-0.12); self._layer_zs = 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_ye = self._spin(0.12)
self._layer_ze = self._spin(-0.12)
lf.addRow("n_bars:", self._layer_nbars) lf.addRow("n_bars:", self._layer_nbars)
lf.addRow("bar_area:", self._layer_area) lf.addRow("bar_area:", self._layer_area)
lf.addRow("y_start:", self._layer_ys) lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs)
lf.addRow("z_start:", self._layer_zs) lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze)
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 = QPushButton("Add layer")
self._add_layer_btn.clicked.connect(self._on_add_layer) self._add_layer_btn.clicked.connect(self._on_add_layer)
lf.addRow(self._add_layer_btn) lf.addRow(self._add_layer_btn)
@ -221,7 +197,8 @@ class FiberSectionEditor(QDialog):
# ── helpers ───────────────────────────────────────────────────── # ── helpers ─────────────────────────────────────────────────────
@staticmethod @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 = QDoubleSpinBox()
sb.setRange(minimum, 1e6) sb.setRange(minimum, 1e6)
sb.setDecimals(6) sb.setDecimals(6)
@ -237,7 +214,7 @@ class FiberSectionEditor(QDialog):
return sb return sb
def _on_patch_type_changed(self, idx: int) -> None: def _on_patch_type_changed(self, idx: int) -> None:
is_rect = idx == 0 is_rect = (idx == 0)
for _, w in self._rect_rows: for _, w in self._rect_rows:
w.setVisible(is_rect) w.setVisible(is_rect)
for _, w in self._circ_rows: for _, w in self._circ_rows:
@ -249,38 +226,28 @@ class FiberSectionEditor(QDialog):
if self._patch_type.currentIndex() == 0: if self._patch_type.currentIndex() == 0:
p = RectangularPatch( p = RectangularPatch(
material_id=mid, material_id=mid,
n_fib_y=self._rect_ny.value(), n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(),
n_fib_z=self._rect_nz.value(), y_i=self._rect_yi.value(), z_i=self._rect_zi.value(),
y_i=self._rect_yi.value(), y_j=self._rect_yj.value(), z_j=self._rect_zj.value(),
z_i=self._rect_zi.value(),
y_j=self._rect_yj.value(),
z_j=self._rect_zj.value(),
) )
self._patches.append(p) self._patches.append(p)
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Rect patch mat={mid} " f"Rect patch mat={mid} "
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) " 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}", f"{p.n_fib_y}×{p.n_fib_z}",
) ))
)
else: else:
p = CircularPatch( p = CircularPatch(
material_id=mid, material_id=mid,
n_fib_circ=self._circ_nc.value(), n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(),
n_fib_rad=self._circ_nr.value(), y_center=self._circ_yc.value(), z_center=self._circ_zc.value(),
y_center=self._circ_yc.value(), r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(),
z_center=self._circ_zc.value(),
r_inner=self._circ_ri.value(),
r_outer=self._circ_ro.value(),
) )
self._patches.append(p) self._patches.append(p)
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Circ patch mat={mid} " f"Circ patch mat={mid} "
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}", f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
) ))
)
self._refresh_preview() self._refresh_preview()
def _on_add_layer(self) -> None: def _on_add_layer(self) -> None:
@ -289,17 +256,13 @@ class FiberSectionEditor(QDialog):
material_id=mid, material_id=mid,
n_bars=self._layer_nbars.value(), n_bars=self._layer_nbars.value(),
bar_area=self._layer_area.value(), bar_area=self._layer_area.value(),
y_start=self._layer_ys.value(), y_start=self._layer_ys.value(), z_start=self._layer_zs.value(),
z_start=self._layer_zs.value(), y_end=self._layer_ye.value(), z_end=self._layer_ze.value(),
y_end=self._layer_ye.value(),
z_end=self._layer_ze.value(),
) )
self._layers.append(lay) self._layers.append(lay)
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}", f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
) ))
)
self._refresh_preview() self._refresh_preview()
def _on_remove(self) -> None: def _on_remove(self) -> None:
@ -317,12 +280,14 @@ class FiberSectionEditor(QDialog):
# ── preview ───────────────────────────────────────────────────── # ── preview ─────────────────────────────────────────────────────
def _refresh_preview(self) -> None: def _refresh_preview(self) -> None:
self._preview.clear() 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) props = compute_section_props(sec)
if props.n_fibres == 0: if props.n_fibres == 0:
self._props_label.setText("Add patches or layers to see the preview.") self._props_label.setText("Add patches or layers to see the preview.")
return return
f = props.fibre_yz
# Color by material: assign a palette index per unique material_id. # Color by material: assign a palette index per unique material_id.
mat_ids = [] mat_ids = []
for p in self._patches: for p in self._patches:
@ -330,9 +295,8 @@ class FiberSectionEditor(QDialog):
for lay in self._layers: for lay in self._layers:
mat_ids.append(lay.material_id) mat_ids.append(lay.material_id)
unique_mats = sorted(set(mat_ids)) if mat_ids else [1] unique_mats = sorted(set(mat_ids)) if mat_ids else [1]
mat_to_color = { mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)]
mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats) for i, mid in enumerate(unique_mats)}
}
# Draw patch fibres as squares, layer fibres as circles. # Draw patch fibres as squares, layer fibres as circles.
# Expand per-patch for color assignment. # Expand per-patch for color assignment.
@ -343,13 +307,9 @@ class FiberSectionEditor(QDialog):
continue continue
color = mat_to_color.get(p.material_id, "#888888") color = mat_to_color.get(p.material_id, "#888888")
self._preview.plot( self._preview.plot(
sub[:, 0], sub[:, 0], sub[:, 1],
sub[:, 1], pen=None, symbol="s", symbolSize=6,
pen=None, symbolBrush=color, symbolPen=None,
symbol="s",
symbolSize=6,
symbolBrush=color,
symbolPen=None,
) )
for lay in self._layers: for lay in self._layers:
sub_sec = FiberSection(id=999999, layers=[lay]) sub_sec = FiberSection(id=999999, layers=[lay])
@ -358,24 +318,16 @@ class FiberSectionEditor(QDialog):
continue continue
color = mat_to_color.get(lay.material_id, "#ff0000") color = mat_to_color.get(lay.material_id, "#ff0000")
self._preview.plot( self._preview.plot(
sub[:, 0], sub[:, 0], sub[:, 1],
sub[:, 1], pen=None, symbol="o", symbolSize=8,
pen=None, symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1),
symbol="o",
symbolSize=8,
symbolBrush=color,
symbolPen=pg.mkPen("#ffffff", width=1),
) )
# Centroid marker # Centroid marker
self._preview.plot( self._preview.plot(
[props.centroid_y], [props.centroid_y], [props.centroid_z],
[props.centroid_z], pen=None, symbol="+", symbolSize=16,
pen=None, symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2),
symbol="+",
symbolSize=16,
symbolBrush=None,
symbolPen=pg.mkPen("#ff0000", width=2),
) )
self._props_label.setText( self._props_label.setText(
@ -389,22 +341,16 @@ class FiberSectionEditor(QDialog):
for p in sec.patches: for p in sec.patches:
self._patches.append(p) self._patches.append(p)
if isinstance(p, RectangularPatch): if isinstance(p, RectangularPatch):
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}", f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
) ))
)
elif isinstance(p, CircularPatch): elif isinstance(p, CircularPatch):
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}", f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
) ))
)
for lay in sec.layers: for lay in sec.layers:
if isinstance(lay, StraightLayer): if isinstance(lay, StraightLayer):
self._layers.append(lay) self._layers.append(lay)
self._item_list.addItem( self._item_list.addItem(QListWidgetItem(
QListWidgetItem(
f"Layer mat={lay.material_id} {lay.n_bars} bars", f"Layer mat={lay.material_id} {lay.n_bars} bars",
) ))
)

View file

@ -19,23 +19,15 @@ from PySide6.QtWidgets import (
) )
from otko.core import ( from otko.core import (
AngleShape,
ElasticSection, ElasticSection,
FiberSection, FiberSection,
PipeShape,
RectShape,
SectionAggregator, SectionAggregator,
) )
def _spin( def _spin(default: float = 0.0, *, decimals: int = 8,
default: float = 0.0, minimum: float = 1e-12, maximum: float = 1e15,
*, step: float = 1.0) -> QDoubleSpinBox:
decimals: int = 8,
minimum: float = 1e-12,
maximum: float = 1e15,
step: float = 1.0,
) -> QDoubleSpinBox:
sb = QDoubleSpinBox() sb = QDoubleSpinBox()
sb.setRange(minimum, maximum) sb.setRange(minimum, maximum)
sb.setDecimals(decimals) sb.setDecimals(decimals)
@ -97,13 +89,14 @@ class SectionFormBase(QWidget):
class ElasticSectionForm(SectionFormBase): class ElasticSectionForm(SectionFormBase):
type_label = "Elastic Section" type_label = "Elastic Section"
#: Shape kind → visible display-only dimensions (see the core #: Shape → visible display-only dimensions. ``None`` hides them all.
#: ``SectionShape`` union). ``None`` hides them all.
_SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = { _SHAPE_DIMS: ClassVar[dict[str | None, tuple[str, ...]]] = {
None: (), None: (),
"rect": ("b", "d"), "rect": ("b", "h"),
"angle": ("b", "d", "t"), "I": ("b", "d", "tw", "tf"),
"pipe": ("od", "t"), "H": ("b", "d", "tw", "tf"),
"pipe": ("diameter",),
"circle": ("diameter",),
} }
def __init__(self, parent: QWidget | None = None) -> None: 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._iy = _spin(8.33e-6, step=1e-7)
self._g = _spin(80e9, step=1e9) self._g = _spin(80e9, step=1e9)
self._j = _spin(1e-6, step=1e-7) self._j = _spin(1e-6, step=1e-7)
for label, w in ( for label, w in (("E:", self._e), ("A:", self._a),
("E:", self._e), ("Iz:", self._iz), ("Iy:", self._iy),
("A:", self._a), ("G:", self._g), ("J:", self._j)):
("Iz:", self._iz),
("Iy:", self._iy),
("G:", self._g),
("J:", self._j),
):
self._layout.addRow(label, w) self._layout.addRow(label, w)
self._layout.addRow(QLabel("<i>Iy, G, J required for 3D models.</i>")) 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._layout.addRow(QLabel("<i>Extruded-view shape (display only).</i>"))
self._shape = QComboBox() self._shape = QComboBox()
self._shape.addItem("(none)", None) 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.addItem(key, key)
self._shape.currentIndexChanged.connect(self._update_dim_visibility) self._shape.currentIndexChanged.connect(self._update_dim_visibility)
self._layout.addRow("Shape:", self._shape) self._layout.addRow("Shape:", self._shape)
self._dim_spins: dict[str, QDoubleSpinBox] = {} self._dim_spins: dict[str, QDoubleSpinBox] = {}
self._dim_labels: dict[str, QLabel] = {} 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() spin = _opt_spin()
lbl = QLabel(f"{key}:") lbl = QLabel(f"{key}:")
self._dim_spins[key] = spin self._dim_spins[key] = spin
@ -162,15 +150,20 @@ class ElasticSectionForm(SectionFormBase):
self._g.setValue(s.G) self._g.setValue(s.G)
if s.J is not None: if s.J is not None:
self._j.setValue(s.J) self._j.setValue(s.J)
kind = getattr(s.shape, "kind", None) if s.shape is not None else None idx = self._shape.findData(s.shape)
idx = self._shape.findData(kind)
self._shape.setCurrentIndex(idx if idx >= 0 else 0) self._shape.setCurrentIndex(idx if idx >= 0 else 0)
for key, spin in self._dim_spins.items(): 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) spin.setValue(v if v is not None else _UNSET_SENTINEL)
self._update_dim_visibility() self._update_dim_visibility()
def _read_specific(self, sid: int) -> ElasticSection: 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( return ElasticSection(
id=sid, id=sid,
name=self._name_edit.text(), name=self._name_edit.text(),
@ -180,38 +173,10 @@ class ElasticSectionForm(SectionFormBase):
Iy=self._iy.value(), Iy=self._iy.value(),
G=self._g.value(), G=self._g.value(),
J=self._j.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): class FiberSectionSummaryForm(SectionFormBase):
"""Read-only overview of a :class:`FiberSection`. """Read-only overview of a :class:`FiberSection`.
@ -231,12 +196,10 @@ class FiberSectionSummaryForm(SectionFormBase):
self._summary.setWordWrap(True) self._summary.setWordWrap(True)
self._summary.setStyleSheet("color: #555;") self._summary.setStyleSheet("color: #555;")
self._layout.addRow(self._summary) self._layout.addRow(self._summary)
self._layout.addRow( self._layout.addRow(QLabel(
QLabel(
"<i>Edit this fiber section from the Section Library list — " "<i>Edit this fiber section from the Section Library list — "
"Add / Modify uses the visual Fiber Section Editor.</i>" "Add / Modify uses the visual Fiber Section Editor.</i>"
) ))
)
self._cached: FiberSection | None = None self._cached: FiberSection | None = None
def _populate_specific(self, s: FiberSection) -> None: def _populate_specific(self, s: FiberSection) -> None:
@ -277,9 +240,9 @@ class SectionAggregatorSummaryForm(SectionFormBase):
def _populate_specific(self, s: SectionAggregator) -> None: def _populate_specific(self, s: SectionAggregator) -> None:
self._cached = s self._cached = s
pairings = ( pairings = "<br>".join(
"<br>".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)" f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings
) ) or "(none)"
self._summary.setText( self._summary.setText(
f"<b>{s.name or 'Section Aggregator'}</b><br>" f"<b>{s.name or 'Section Aggregator'}</b><br>"
f"Wraps section: {s.section_id}<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 # Graceful fallback — unknown section types display a minimal
# placeholder instead of crashing the entire dialog. # placeholder instead of crashing the entire dialog.
form = SectionFormBase() form = SectionFormBase()
form._layout.addRow( form._layout.addRow(QLabel(
QLabel(f"<i>No form registered for section type " f"<b>{section.type}</b> yet.</i>") f"<i>No form registered for section type "
) f"<b>{section.type}</b> yet.</i>"
))
form._section_id = section.id form._section_id = section.id
form._name_edit.setText(getattr(section, "name", "") or "") form._name_edit.setText(getattr(section, "name", "") or "")
form._name_edit.setEnabled(False) form._name_edit.setEnabled(False)

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from pydantic import ValidationError
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
@ -18,6 +17,7 @@ from PySide6.QtWidgets import (
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
from pydantic import ValidationError
from otko.commands import ( from otko.commands import (
AddSectionsCommand, AddSectionsCommand,
@ -113,7 +113,6 @@ class SectionLibraryDialog(QDialog):
""" """
from otko.core import FiberSection from otko.core import FiberSection
from otko.views.dialogs.section_editor import FiberSectionEditor from otko.views.dialogs.section_editor import FiberSectionEditor
sec = self._selected_section() sec = self._selected_section()
if not isinstance(sec, FiberSection) or self._vm.project is None: if not isinstance(sec, FiberSection) or self._vm.project is None:
return return
@ -157,12 +156,7 @@ class SectionLibraryDialog(QDialog):
summary_kinds = {"FiberSection", "SectionAggregator"} summary_kinds = {"FiberSection", "SectionAggregator"}
kinds = [k for k in FORM_REGISTRY if k not in summary_kinds] kinds = [k for k in FORM_REGISTRY if k not in summary_kinds]
kind, ok = QInputDialog.getItem( kind, ok = QInputDialog.getItem(
self, self, "Add section", "Type:", kinds, current=0, editable=False,
"Add section",
"Type:",
kinds,
current=0,
editable=False,
) )
if not ok: if not ok:
return return
@ -182,7 +176,6 @@ class SectionLibraryDialog(QDialog):
if self._vm.project is None: if self._vm.project is None:
return return
from otko.views.dialogs.section_editor import FiberSectionEditor from otko.views.dialogs.section_editor import FiberSectionEditor
mat_ids = [m.id for m in self._vm.project.materials] mat_ids = [m.id for m in self._vm.project.materials]
new_id = self._vm.project.next_section_id() new_id = self._vm.project.next_section_id()
dlg = FiberSectionEditor(mat_ids, parent=self) dlg = FiberSectionEditor(mat_ids, parent=self)
@ -203,10 +196,9 @@ class SectionLibraryDialog(QDialog):
if section is None: if section is None:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, self, "Delete section",
"Delete section",
f"Delete section #{section.id} ({section.type})?\n" 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: if reply != QMessageBox.StandardButton.Yes:
return return

View file

@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
from otko.core import Project, UniformExcitationPattern from otko.core import Project, UniformExcitationPattern
_DIRECTION_CHOICES: list[tuple[int, str]] = [ _DIRECTION_CHOICES: list[tuple[int, str]] = [
(1, "1 — X (horizontal)"), (1, "1 — X (horizontal)"),
(2, "2 — Y (vertical for ndm=2, lateral for ndm=3)"), (2, "2 — Y (vertical for ndm=2, lateral for ndm=3)"),
@ -50,12 +51,10 @@ class UniformExcitationDialog(QDialog):
def _build_ui(self) -> None: def _build_ui(self) -> None:
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.addWidget( root.addWidget(QLabel(
QLabel(
"<b>UniformExcitation</b> — apply a base ground motion " "<b>UniformExcitation</b> — apply a base ground motion "
"to every free node in the chosen DOF direction." "to every free node in the chosen DOF direction."
) ))
)
form = QFormLayout() form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion") self._name_edit = QLineEdit("GroundMotion")
@ -85,13 +84,16 @@ class UniformExcitationDialog(QDialog):
self._factor_spin.setDecimals(6) self._factor_spin.setDecimals(6)
self._factor_spin.setSingleStep(0.1) self._factor_spin.setSingleStep(0.1)
self._factor_spin.setValue(1.0) 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) form.addRow("Factor:", self._factor_spin)
root.addLayout(form) root.addLayout(form)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from contextlib import suppress from contextlib import suppress
from PySide6.QtCore import Qt 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 ( from otko.services.deformation import (
linear_static_auto_scale, linear_static_auto_scale,
@ -34,7 +34,6 @@ from otko.services.results import (
) )
from otko.views.canvas3d.model_renderer import RendererMode from otko.views.canvas3d.model_renderer import RendererMode
from otko.views.docks import ( from otko.views.docks import (
ConsoleDock,
DeformedShapeView, DeformedShapeView,
ForceDiagramView, ForceDiagramView,
HysteresisView, HysteresisView,
@ -104,9 +103,13 @@ class DockManager:
self.tabifyDockWidget(props_dock, tree_dock) self.tabifyDockWidget(props_dock, tree_dock)
tree_dock.raise_() tree_dock.raise_()
self._console_dock = ConsoleDock(self) self._console = QPlainTextEdit()
self._console = self._console_dock.view self._console.setReadOnly(True)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._console_dock) 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() self._table = TableDock()
table_dock = QDockWidget("Table", self) table_dock = QDockWidget("Table", self)
@ -119,7 +122,7 @@ class DockManager:
results_dock.setWidget(self._results_panel) results_dock.setWidget(self._results_panel)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, results_dock) self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, results_dock)
self._results_dock = 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) self.tabifyDockWidget(table_dock, results_dock)
table_dock.raise_() table_dock.raise_()

View file

@ -1,6 +1,5 @@
"""Reusable dock widget contents.""" """Reusable dock widget contents."""
from otko.views.docks.console import ConsoleDock
from otko.views.docks.deformed_shape import DeformedShapeView from otko.views.docks.deformed_shape import DeformedShapeView
from otko.views.docks.force_diagram import ForceDiagramView from otko.views.docks.force_diagram import ForceDiagramView
from otko.views.docks.hysteresis import HysteresisView 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 from otko.views.docks.time_history import TimeHistoryView
__all__ = [ __all__ = [
"PropertyEditorDock", "PropertyEditorDock", "ResultsPanel", "TableDock",
"ResultsPanel", "DeformedShapeView", "ModeShapeAnimator",
"TableDock", "ForceDiagramView", "TimeHistoryView", "HysteresisView",
"DeformedShapeView", "PushoverCurveView", "ResponseSpectrumView",
"ModeShapeAnimator",
"ForceDiagramView",
"TimeHistoryView",
"HysteresisView",
"PushoverCurveView",
"ResponseSpectrumView",
"ConsoleDock",
] ]

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 __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Callable
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -31,7 +31,8 @@ class DeformedShapeView(QWidget):
scaleChanged = Signal(float) scaleChanged = Signal(float)
closed = Signal() 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) super().__init__(parent)
self._suggested = max(suggested_scale, 1e-6) self._suggested = max(suggested_scale, 1e-6)
self._build_ui() self._build_ui()
@ -40,9 +41,9 @@ class DeformedShapeView(QWidget):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8) layout.setContentsMargins(8, 8, 8, 8)
layout.addWidget(QLabel("<b>Deformed Shape</b>")) layout.addWidget(QLabel("<b>Deformed Shape</b>"))
layout.addWidget( layout.addWidget(QLabel(
QLabel("<i>The slider scales displacement around the suggested factor.</i>") "<i>The slider scales displacement around the suggested factor.</i>"
) ))
form = QFormLayout() form = QFormLayout()

View file

@ -10,6 +10,8 @@ Exposes two signals:
from __future__ import annotations from __future__ import annotations
import math
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
@ -33,7 +35,8 @@ class ForceDiagramView(QWidget):
changed = Signal(object, float) # (ForceComponent, scale) changed = Signal(object, float) # (ForceComponent, scale)
closed = Signal() 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) super().__init__(parent)
self._scale_base = max(suggested_scale, 1e-12) self._scale_base = max(suggested_scale, 1e-12)
self._pending_component_change = False self._pending_component_change = False

View file

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

View file

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

View file

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

View file

@ -20,7 +20,6 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from otko.services.combinations import EnvelopeResults
from otko.services.results import ( from otko.services.results import (
ModalResults, ModalResults,
StaticResults, StaticResults,
@ -60,18 +59,7 @@ class ResultsPanel(QWidget):
""" """
self._ndm, self._ndf = ndm, ndf self._ndm, self._ndf = ndm, ndf
self._tabs.clear() self._tabs.clear()
if isinstance(results, EnvelopeResults): if isinstance(results, StaticResults):
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):
self._title.setText( self._title.setText(
f"<b>Static — case #{results.case_id} '{results.case_name}'</b> " f"<b>Static — case #{results.case_id} '{results.case_name}'</b> "
f"({results.n_steps} step(s))" 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_disp_table(results), "Displacements")
self._tabs.addTab(self._build_static_reaction_table(results), "Reactions") self._tabs.addTab(self._build_static_reaction_table(results), "Reactions")
elif isinstance(results, ModalResults): 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") self._tabs.addTab(self._build_modal_table(results), "Frequencies")
elif isinstance(results, TransientResults): elif isinstance(results, TransientResults):
self._title.setText( self._title.setText(
@ -119,28 +109,6 @@ class ResultsPanel(QWidget):
self._set_cell(table, i, j + 1, f"{val:.6g}") self._set_cell(table, i, j + 1, f"{val:.6g}")
return self._wrap(table, "Final-step reactions") 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: def _build_modal_table(self, r: ModalResults) -> QWidget:
n = len(r.eigenvalues) n = len(r.eigenvalues)
headers = ["Mode", "Eigenvalue (rad²/s²)", "ω (rad/s)", "f (Hz)", "T (s)"] 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: def _transient_summary(self, r: TransientResults) -> QWidget:
w = QWidget() w = QWidget()
layout = QVBoxLayout(w) layout = QVBoxLayout(w)
layout.addWidget( layout.addWidget(QLabel(
QLabel(
f"<b>Steps:</b> {r.n_steps}<br>" f"<b>Steps:</b> {r.n_steps}<br>"
f"<b>dt:</b> {r.dt:g}<br>" f"<b>dt:</b> {r.dt:g}<br>"
f"<b>Total time:</b> {r.n_steps * 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>" f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
) ))
) layout.addWidget(QLabel(
layout.addWidget( "<i>Time-history plots and animation will appear here in Phase 7.</i>"
QLabel("<i>Time-history plots and animation will appear here in Phase 7.</i>") ))
)
layout.addStretch(1) layout.addStretch(1)
return w return w

View file

@ -35,7 +35,6 @@ from PySide6.QtWidgets import (
from otko.commands import ( from otko.commands import (
AddAnalysisCasesCommand, AddAnalysisCasesCommand,
AddCombinationsCommand,
AddElementLoadsCommand, AddElementLoadsCommand,
AddElementsCommand, AddElementsCommand,
AddEqualDOFConstraintCommand, AddEqualDOFConstraintCommand,
@ -48,7 +47,6 @@ from otko.commands import (
AssignMaterialCommand, AssignMaterialCommand,
AssignSectionCommand, AssignSectionCommand,
DeleteAnalysisCasesCommand, DeleteAnalysisCasesCommand,
DeleteCombinationsCommand,
DeleteElementLoadsCommand, DeleteElementLoadsCommand,
DeleteElementsCommand, DeleteElementsCommand,
DeleteEqualDOFConstraintCommand, DeleteEqualDOFConstraintCommand,
@ -61,7 +59,6 @@ from otko.commands import (
SetMassCommand, SetMassCommand,
SetRestraintCommand, SetRestraintCommand,
UpdateAnalysisCaseCommand, UpdateAnalysisCaseCommand,
UpdateCombinationCommand,
UpdateElementFieldsCommand, UpdateElementFieldsCommand,
UpdateElementLoadCommand, UpdateElementLoadCommand,
UpdateEqualDOFConstraintCommand, UpdateEqualDOFConstraintCommand,
@ -78,8 +75,6 @@ from otko.core import (
ElasticUniaxial, ElasticUniaxial,
EqualDOFConstraint, EqualDOFConstraint,
LinearTimeSeries, LinearTimeSeries,
LoadCombination,
LoadCombinationItem,
NodalLoad, NodalLoad,
Node, Node,
PlainLoadPattern, PlainLoadPattern,
@ -174,7 +169,6 @@ _ANALYSIS_ATTRS = [
"tolerance", "tolerance",
"max_iter", "max_iter",
] ]
_COMBO_HEADERS = ["id", "name", "kind", "cases", "factors"]
_TAB_ORDER = ( _TAB_ORDER = (
"Nodes", "Nodes",
@ -187,7 +181,6 @@ _TAB_ORDER = (
"Element Loads", "Element Loads",
"Constraints", "Constraints",
"Analyses", "Analyses",
"Combinations",
) )
@ -197,7 +190,7 @@ def _fmt(value: Any) -> str:
return "—" return "—"
if isinstance(value, float): if isinstance(value, float):
return f"{value:g}" return f"{value:g}"
if isinstance(value, list | tuple): if isinstance(value, (list, tuple)):
return ", ".join(_fmt(v) for v in value) return ", ".join(_fmt(v) for v in value)
return str(value) return str(value)
@ -277,7 +270,6 @@ class TableDock(QWidget):
"Element Loads": self._load_element_loads, "Element Loads": self._load_element_loads,
"Constraints": self._load_constraints, "Constraints": self._load_constraints,
"Analyses": self._load_analyses, "Analyses": self._load_analyses,
"Combinations": self._load_combinations,
} }
for name in _TAB_ORDER: for name in _TAB_ORDER:
page = QWidget() page = QWidget()
@ -425,7 +417,6 @@ class TableDock(QWidget):
"Element Loads": self._commit_element_load, "Element Loads": self._commit_element_load,
"Constraints": self._commit_constraint, "Constraints": self._commit_constraint,
"Analyses": self._commit_analysis, "Analyses": self._commit_analysis,
"Combinations": self._commit_combination,
}[tab] }[tab]
handler(self._tables[tab], item) handler(self._tables[tab], item)
except (ValueError, KeyError, IndexError) as exc: except (ValueError, KeyError, IndexError) as exc:
@ -1035,7 +1026,7 @@ class TableDock(QWidget):
try: try:
key = int(pid) key = int(pid)
label = name_of.get(key, str(key)) or str(key) 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): except (TypeError, ValueError):
continue continue
return ", ".join(parts) if parts else "—" return ", ".join(parts) if parts else "—"
@ -1104,49 +1095,6 @@ class TableDock(QWidget):
updated = self._dry_run(case, {attr: value}) updated = self._dry_run(case, {attr: value})
self._dispatch(UpdateAnalysisCaseCommand(vm, updated)) 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 ────────────────────────────────────────── # ── Add / Delete rows ──────────────────────────────────────────
def _require_project(self, tab: str) -> Any | None: def _require_project(self, tab: str) -> Any | None:
if self._vm is None or self._project is 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], pattern_ids=[project.load_patterns[0].id],
) )
self._dispatch(AddAnalysisCasesCommand(vm, [case])) 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: except Exception as exc:
label = tab[:-1] if tab.endswith("s") else tab label = tab[:-1] if tab.endswith("s") else tab
self._say(f"Add {label} failed: {exc}") self._say(f"Add {label} failed: {exc}")
@ -1326,8 +1264,6 @@ class TableDock(QWidget):
self._dispatch(DeleteLoadPatternCommand(vm, ids)) self._dispatch(DeleteLoadPatternCommand(vm, ids))
elif tab == "Analyses": elif tab == "Analyses":
self._dispatch(DeleteAnalysisCasesCommand(vm, ids)) self._dispatch(DeleteAnalysisCasesCommand(vm, ids))
elif tab == "Combinations":
self._dispatch(DeleteCombinationsCommand(vm, ids))
except Exception as exc: except Exception as exc:
self._say(f"Delete {tab} failed: {exc}") self._say(f"Delete {tab} failed: {exc}")
self.refresh() self.refresh()

View file

@ -10,9 +10,9 @@ runner to record additional series (planned for a later phase).
from __future__ import annotations from __future__ import annotations
import contextlib
from typing import Any from typing import Any
import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PySide6.QtCore import Signal from PySide6.QtCore import Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -208,7 +208,9 @@ class TimeHistoryView(QWidget):
def _clear_traces(self) -> None: def _clear_traces(self) -> None:
for _, _, item in self._traces: for _, _, item in self._traces:
with contextlib.suppress(Exception): try:
self._plot.removeItem(item) self._plot.removeItem(item)
except Exception:
pass
self._traces.clear() self._traces.clear()
self._trace_list.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 pathlib import Path
from PySide6.QtCore import QSettings from PySide6.QtGui import QIcon
from PySide6.QtGui import QCloseEvent, QIcon from PySide6.QtWidgets import QMainWindow
from PySide6.QtWidgets import QMainWindow, QMessageBox
from otko.viewmodels import AnalysisRunner, ProjectViewModel from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers from otko.views.action_handlers import ActionHandlers
@ -75,7 +74,6 @@ class MainWindow(
self._build_status_bar() self._build_status_bar()
self._wire() self._wire()
self._refresh_action_enablement() self._refresh_action_enablement()
self.restore_layout()
# ── construction ───────────────────────────────────────────────── # ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None: def _build_central_canvas(self) -> None:
@ -84,54 +82,6 @@ class MainWindow(
# Diagram overlay paints onto the same plotter as the model. # Diagram overlay paints onto the same plotter as the model.
self._diagram_renderer = DiagramRenderer(self._canvas) 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 ──────────────────────────────────────────────────────── # ── wiring ────────────────────────────────────────────────────────
def _wire(self) -> None: def _wire(self) -> None:
# File # File
@ -191,7 +141,6 @@ class MainWindow(
# Analyze # Analyze
self._act_case_manager.triggered.connect(self._on_case_manager) 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) self._act_run.triggered.connect(self._on_run_analysis)
# Display # Display
@ -207,7 +156,7 @@ class MainWindow(
self._act_back_to_model.triggered.connect(self._on_back_to_model) self._act_back_to_model.triggered.connect(self._on_back_to_model)
# AnalysisRunner: stream log to console + show results in panel # 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.finished.connect(self._on_analysis_finished)
self._runner.failed.connect(self._on_analysis_failed) self._runner.failed.connect(self._on_analysis_failed)

View file

@ -155,7 +155,6 @@ class MenuBuilder:
# Analyze # Analyze
self._act_case_manager = QAction("&Cases…", self, shortcut="Ctrl+Shift+A") 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") self._act_run = QAction("&Run…", self, shortcut="F5")
# View (Iso/Top/Front/Right are an exclusive checkable group so the # View (Iso/Top/Front/Right are an exclusive checkable group so the
@ -286,11 +285,6 @@ class MenuBuilder:
"Analysis Cases (Ctrl+Shift+A)", "Analysis Cases (Ctrl+Shift+A)",
"Manage analysis cases.", "Manage analysis cases.",
), ),
"_act_combo_manager": (
"cases",
"Load Combinations",
"Manage named case-result load combinations.",
),
"_act_show_deformed": ( "_act_show_deformed": (
"show-deformed", "show-deformed",
"Show Deformed Shape", "Show Deformed Shape",
@ -426,7 +420,6 @@ class MenuBuilder:
m_analyze = mb.addMenu("&Analyze") m_analyze = mb.addMenu("&Analyze")
m_analyze.addAction(self._act_case_manager) m_analyze.addAction(self._act_case_manager)
m_analyze.addAction(self._act_combo_manager)
m_analyze.addSeparator() m_analyze.addSeparator()
m_analyze.addAction(self._act_run) m_analyze.addAction(self._act_run)
@ -489,7 +482,7 @@ class MenuBuilder:
tb.setMovable(True) tb.setMovable(True)
tb.setIconSize(QSize(24, 24)) tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) 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.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
self._view_toolbar = tb self._view_toolbar = tb
tb.addAction(self._act_zoom_extents) tb.addAction(self._act_zoom_extents)
@ -535,8 +528,9 @@ class MenuBuilder:
tb.setMovable(True) tb.setMovable(True)
tb.setIconSize(QSize(24, 24)) tb.setIconSize(QSize(24, 24))
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
# Top edge alongside the View toolbar (built just before). # View lives on the top edge, so Tools stays left independently
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb) # in its own left-area column.
self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, tb)
self._tools_toolbar = tb self._tools_toolbar = tb
tb.addAction(self._act_tool_select) tb.addAction(self._act_tool_select)
tb.addAction(self._act_tool_draw_node) tb.addAction(self._act_tool_draw_node)

View file

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

View file

@ -5,11 +5,5 @@ from otko.views.tools.draw_frame import DrawFrameTool
from otko.views.tools.draw_node import DrawNodeTool from otko.views.tools.draw_node import DrawNodeTool
from otko.views.tools.draw_truss import DrawTrussTool from otko.views.tools.draw_truss import DrawTrussTool
__all__ = [ __all__ = ["CanvasTool", "SelectTool", "ToolController",
"CanvasTool", "DrawFrameTool", "DrawNodeTool", "DrawTrussTool"]
"SelectTool",
"ToolController",
"DrawFrameTool",
"DrawNodeTool",
"DrawTrussTool",
]

View file

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

View file

@ -41,9 +41,9 @@ class DrawFrameTool(LinearElementDrawTool):
def __init__( def __init__(
self, self,
canvas: ModelCanvas, canvas: "ModelCanvas",
vm: ProjectViewModel, vm: "ProjectViewModel",
parent: QObject | None = None, parent: "QObject | None" = None,
) -> None: ) -> None:
super().__init__(canvas, vm, parent) super().__init__(canvas, vm, parent)
@ -67,9 +67,7 @@ class DrawFrameTool(LinearElementDrawTool):
section_id = self._ensure_default_section() section_id = self._ensure_default_section()
element_id = project.next_element_id() element_id = project.next_element_id()
elem = ElasticBeamColumn( elem = ElasticBeamColumn(
id=element_id, id=element_id, nodes=(n1, n2), section_id=section_id,
nodes=(n1, n2),
section_id=section_id,
) )
self._vm.apply_command(AddElementsCommand(self._vm, [elem])) self._vm.apply_command(AddElementsCommand(self._vm, [elem]))
finally: finally:

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