Compare commits
188 changed files with 2051 additions and 5969 deletions
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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
70
NOTICE
|
|
@ -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.
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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]] = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]] = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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 "
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}))
|
|
||||||
|
|
@ -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.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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 — Draw the model</h4>
|
<h4>4 — 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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
))
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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_()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in a new issue