chore/adopt-local-tree #4
152 changed files with 3394 additions and 1651 deletions
chore: adopt remaining local development state
Catch-all for the intermixed residue of the unpushed otko-development work ported into this tree: combinations/console-dock/quick-guide wiring across commands, core, services, views and tests; repo-wide ruff-format normalization; README/CONTRIBUTING updates; and the toolbar default (both toolbars now open in the top area, quick guide text updated). Splitting this further would require hunk-level surgery with low confidence; the preceding commits in this branch isolate the self-contained features.
commit
ba783718d4
|
|
@ -1,37 +1,97 @@
|
|||
# Contributing
|
||||
|
||||
Early-stage project. The bar is architecture cleanliness, not feature
|
||||
count. If your change breaks a layering rule below, it won't merge —
|
||||
no matter how useful the feature.
|
||||
Thanks for helping with OTKO — a SAP2000-style desktop GUI for
|
||||
OpenSeesPy. Early-stage project: the bar is architecture cleanliness, not
|
||||
feature count. If your change breaks a layering rule below, it won't
|
||||
merge — no matter how useful the feature.
|
||||
|
||||
## Dev setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
source .venv/bin/activate # Linux / macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
pip install -e ".[gui,dev]"
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
`pip install -e ".[gui,dev]"` pulls the Qt/PyVista desktop stack plus the
|
||||
dev tools. For a headless checkout (core + services only, no Qt) use
|
||||
`pip install -e .` instead. Python 3.10+; on Windows use 3.12+.
|
||||
|
||||
Launch the GUI with:
|
||||
|
||||
```bash
|
||||
python -m otko
|
||||
```
|
||||
|
||||
`pre-commit install` wires ruff + mypy into your local git hooks so
|
||||
obvious issues are caught before a commit. Run it once per clone.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
Run the verify commands in this order and make sure they are all clean:
|
||||
|
||||
```bash
|
||||
ruff check src tests
|
||||
ruff format src tests
|
||||
ruff format src tests # line-length 100, E501 ignored
|
||||
mypy src/otko/core src/otko/services
|
||||
pytest -m "not slow"
|
||||
pytest -m "not slow" # CI gate: lint → this, on 3.10/3.11/3.12 × ubuntu/windows/macos
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
1. `core/` may not import Qt or `openseespy`. Period.
|
||||
2. `services/` may not import Qt.
|
||||
3. `views/` may not import `openseespy` directly — go through a service.
|
||||
4. Public functions and methods need type hints and a docstring.
|
||||
5. New domain entities go through Pydantic validation.
|
||||
6. Long-running operations (>50 ms) run off the GUI thread.
|
||||
OTKO uses a strict one-way **MVVM + service** architecture. Dependencies
|
||||
flow outward-in only:
|
||||
|
||||
```
|
||||
views → viewmodels → services → core
|
||||
```
|
||||
|
||||
`commands` sits alongside the bridge and owns every model mutation.
|
||||
|
||||
1. `core/` is pure Python — stdlib + numpy + pydantic. It may **not**
|
||||
import Qt or `openseespy`. Period.
|
||||
2. `services/` may use `core` + `h5py` + `openseespy`, but may **not**
|
||||
import Qt.
|
||||
3. `views/` (PySide6/pyvistaqt) may **not** import `openseespy` directly —
|
||||
go through a service. No business logic in `views`.
|
||||
4. `viewmodels/` bridges `core` ↔ Qt (signals, `QUndoStack`).
|
||||
5. `commands/` holds the `QUndoCommand` subclasses; all model mutations
|
||||
go through `commands`, not ad-hoc edits in `views`.
|
||||
6. Public functions and methods need type hints and a docstring.
|
||||
7. New domain entities go through Pydantic validation.
|
||||
8. Long-running operations (>50 ms) run off the GUI thread (`AnalysisWorker`
|
||||
in a `QThread`, cancelled via `isInterruptionRequested()`; results cross
|
||||
threads as a lightweight `ResultsHandle` written to HDF5).
|
||||
|
||||
The full package map and the fixed OpenSeesPy command order the runner
|
||||
emits live in [`docs/architecture.md`](docs/architecture.md). Never
|
||||
reorder the runner's commands.
|
||||
|
||||
## Branch model
|
||||
|
||||
`main` is the default and integration branch. Work on short-lived topic
|
||||
branches cut from `main` — `feat/<topic>`, `fix/<topic>`, or
|
||||
`docs/<topic>` — and open a pull request against `main`. Do not commit
|
||||
directly to `main` from an agent session. There is no `develop` branch.
|
||||
|
||||
## Commit style
|
||||
|
||||
Conventional Commits — `feat:`, `fix:`, `refactor:`, `docs:`, `test:`,
|
||||
`chore:`, `ci:`.
|
||||
`chore:`, `ci:`. Keep each commit focused; a `feat:` commit should add a
|
||||
feature, not mix one in with unrelated refactors.
|
||||
|
||||
## Documentation
|
||||
|
||||
If your change is user-visible, update
|
||||
[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) and the relevant roadmap or
|
||||
ADR entry. Project model files are `.osmodel` (Pydantic-validated JSON);
|
||||
regenerate the checked-in `examples/*.osmodel` from their scripts with
|
||||
`python examples/<name>.py` rather than hand-editing them.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ views (Qt) → viewmodels → services (OpenSeesRunner, Persistence) → c
|
|||
Long version in [`docs/architecture.md`](docs/architecture.md),
|
||||
including the OpenSeesPy command order the runner emits.
|
||||
|
||||
## Documentation
|
||||
|
||||
Practical, task-first walkthroughs live in
|
||||
[`docs/QUICK_GUIDE.md`](docs/QUICK_GUIDE.md) — a cantilever build,
|
||||
modal analysis, report/script export, display units, and undo/redo.
|
||||
The full index is [`docs/README.md`](docs/README.md).
|
||||
|
||||
## Install (development)
|
||||
|
||||
**Desktop GUI** (Qt, PyVista, pyqtgraph, imageio):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ from otko.commands.analysis import (
|
|||
UpdateAnalysisCaseCommand,
|
||||
)
|
||||
from otko.commands.base import ProjectCommand
|
||||
from otko.commands.combinations import (
|
||||
AddCombinationsCommand,
|
||||
DeleteCombinationsCommand,
|
||||
UpdateCombinationCommand,
|
||||
)
|
||||
from otko.commands.constraints import (
|
||||
AddEqualDOFConstraintCommand,
|
||||
DeleteEqualDOFConstraintCommand,
|
||||
|
|
@ -67,25 +72,50 @@ from otko.commands.transforms import (
|
|||
|
||||
__all__ = [
|
||||
"ProjectCommand",
|
||||
"AddNodesCommand", "DeleteNodesCommand", "SetRestraintCommand", "SetMassCommand",
|
||||
"AddNodesCommand",
|
||||
"DeleteNodesCommand",
|
||||
"SetRestraintCommand",
|
||||
"SetMassCommand",
|
||||
"UpdateNodeFieldsCommand",
|
||||
"AddElementsCommand", "DeleteElementsCommand",
|
||||
"AssignSectionCommand", "AssignMaterialCommand",
|
||||
"ReplaceElementsCommand", "ConvertElementTypeCommand",
|
||||
"AddElementsCommand",
|
||||
"DeleteElementsCommand",
|
||||
"AssignSectionCommand",
|
||||
"AssignMaterialCommand",
|
||||
"ReplaceElementsCommand",
|
||||
"ConvertElementTypeCommand",
|
||||
"UpdateElementFieldsCommand",
|
||||
"AddMaterialsCommand", "DeleteMaterialsCommand", "UpdateMaterialCommand",
|
||||
"AddSectionsCommand", "DeleteSectionsCommand", "UpdateSectionCommand",
|
||||
"AddNodalLoadsCommand", "AddElementLoadsCommand",
|
||||
"UpdateNodalLoadCommand", "DeleteNodalLoadsCommand",
|
||||
"UpdateElementLoadCommand", "DeleteElementLoadsCommand",
|
||||
"MoveNodesCommand", "ReplicateCommand", "MirrorCommand", "Plane",
|
||||
"AddAnalysisCasesCommand", "DeleteAnalysisCasesCommand", "UpdateAnalysisCaseCommand",
|
||||
"AddMaterialsCommand",
|
||||
"DeleteMaterialsCommand",
|
||||
"UpdateMaterialCommand",
|
||||
"AddSectionsCommand",
|
||||
"DeleteSectionsCommand",
|
||||
"UpdateSectionCommand",
|
||||
"AddNodalLoadsCommand",
|
||||
"AddElementLoadsCommand",
|
||||
"UpdateNodalLoadCommand",
|
||||
"DeleteNodalLoadsCommand",
|
||||
"UpdateElementLoadCommand",
|
||||
"DeleteElementLoadsCommand",
|
||||
"MoveNodesCommand",
|
||||
"ReplicateCommand",
|
||||
"MirrorCommand",
|
||||
"Plane",
|
||||
"AddAnalysisCasesCommand",
|
||||
"DeleteAnalysisCasesCommand",
|
||||
"UpdateAnalysisCaseCommand",
|
||||
"AddCombinationsCommand",
|
||||
"DeleteCombinationsCommand",
|
||||
"UpdateCombinationCommand",
|
||||
"SetGridSystemCommand",
|
||||
"SetCoordSystemsCommand",
|
||||
"AddTimeSeriesCommand", "AddLoadPatternCommand",
|
||||
"UpdateTimeSeriesCommand", "DeleteTimeSeriesCommand",
|
||||
"UpdateLoadPatternCommand", "DeleteLoadPatternCommand",
|
||||
"AddTimeSeriesCommand",
|
||||
"AddLoadPatternCommand",
|
||||
"UpdateTimeSeriesCommand",
|
||||
"DeleteTimeSeriesCommand",
|
||||
"UpdateLoadPatternCommand",
|
||||
"DeleteLoadPatternCommand",
|
||||
"AddEqualDOFConstraintCommand",
|
||||
"UpdateEqualDOFConstraintCommand", "DeleteEqualDOFConstraintCommand",
|
||||
"UpdateEqualDOFConstraintCommand",
|
||||
"DeleteEqualDOFConstraintCommand",
|
||||
"SetUnitsCommand",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ if TYPE_CHECKING:
|
|||
class AddAnalysisCasesCommand(ProjectCommand):
|
||||
"""Add one or more analysis cases."""
|
||||
|
||||
def __init__(self, vm: "ProjectViewModel", cases: list[Any], *,
|
||||
text: str | None = None) -> None:
|
||||
def __init__(
|
||||
self, vm: "ProjectViewModel", cases: list[Any], *, text: str | None = None
|
||||
) -> None:
|
||||
super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
|
||||
self._cases = list(cases)
|
||||
|
||||
|
|
@ -42,12 +43,9 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
|
|||
|
||||
def redo(self) -> None:
|
||||
self._removed = [
|
||||
(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
|
||||
(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._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
|
|
|
|||
|
|
@ -38,9 +38,7 @@ class ProjectCommand(QUndoCommand):
|
|||
@property
|
||||
def project(self) -> "Project":
|
||||
if self._vm.project is None:
|
||||
raise RuntimeError(
|
||||
f"Cannot apply '{self.text()}': no active project."
|
||||
)
|
||||
raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
|
||||
return self._vm.project
|
||||
|
||||
def _notify(self) -> None:
|
||||
|
|
|
|||
|
|
@ -34,8 +34,7 @@ class AddEqualDOFConstraintCommand(ProjectCommand):
|
|||
|
||||
def undo(self) -> None:
|
||||
self.project.mp_constraints[:] = [
|
||||
mp for mp in self.project.mp_constraints
|
||||
if mp != self._constraint
|
||||
mp for mp in self.project.mp_constraints if mp != self._constraint
|
||||
]
|
||||
self._notify()
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ class AddNodalLoadsCommand(ProjectCommand):
|
|||
self._forces = forces
|
||||
self._pattern_id = pattern_id
|
||||
self._new_pattern_name = new_pattern_name
|
||||
self._new_ts_type = new_ts_type # "Linear" or "Constant"
|
||||
self._new_ts_type = new_ts_type # "Linear" or "Constant"
|
||||
self._created_ts: TimeSeries | None = None
|
||||
self._created_pattern: PlainLoadPattern | None = None
|
||||
self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load)
|
||||
|
|
@ -101,7 +101,10 @@ class AddNodalLoadsCommand(ProjectCommand):
|
|||
break
|
||||
self._added_loads.clear()
|
||||
# Roll back any infrastructure we created.
|
||||
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
|
||||
if (
|
||||
self._created_pattern is not None
|
||||
and self._created_pattern in self.project.load_patterns
|
||||
):
|
||||
self.project.load_patterns.remove(self._created_pattern)
|
||||
self._created_pattern = None
|
||||
if self._created_ts is not None and self._created_ts in self.project.time_series:
|
||||
|
|
@ -157,7 +160,10 @@ class AddElementLoadsCommand(ProjectCommand):
|
|||
pattern = self._resolve_pattern()
|
||||
for eid in self._element_ids:
|
||||
load = UniformElementLoad(
|
||||
element_id=eid, wy=self._wy, wz=self._wz, wx=self._wx,
|
||||
element_id=eid,
|
||||
wy=self._wy,
|
||||
wz=self._wz,
|
||||
wx=self._wx,
|
||||
)
|
||||
pattern.element_loads.append(load)
|
||||
self._added_loads.append((pattern.id, load))
|
||||
|
|
@ -171,7 +177,10 @@ class AddElementLoadsCommand(ProjectCommand):
|
|||
pat.element_loads.remove(load)
|
||||
break
|
||||
self._added_loads.clear()
|
||||
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
|
||||
if (
|
||||
self._created_pattern is not None
|
||||
and self._created_pattern in self.project.load_patterns
|
||||
):
|
||||
self.project.load_patterns.remove(self._created_pattern)
|
||||
self._created_pattern = None
|
||||
if self._created_ts is not None and self._created_ts in self.project.time_series:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ if TYPE_CHECKING:
|
|||
class AddMaterialsCommand(ProjectCommand):
|
||||
"""Add one or more materials in a single undoable step."""
|
||||
|
||||
def __init__(self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None) -> None:
|
||||
def __init__(
|
||||
self, vm: "ProjectViewModel", materials: list[Any], *, text: str | None = None
|
||||
) -> None:
|
||||
super().__init__(vm, text or f"Add {len(materials)} material(s)")
|
||||
self._materials = list(materials)
|
||||
|
||||
|
|
@ -41,8 +43,7 @@ class DeleteMaterialsCommand(ProjectCommand):
|
|||
|
||||
def redo(self) -> None:
|
||||
self._removed = [
|
||||
(i, m) for i, m in enumerate(self.project.materials)
|
||||
if m.id in self._material_ids
|
||||
(i, m) for i, m in enumerate(self.project.materials) if m.id in self._material_ids
|
||||
]
|
||||
self.project.materials[:] = [
|
||||
m for m in self.project.materials if m.id not in self._material_ids
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ if TYPE_CHECKING:
|
|||
class AddNodesCommand(ProjectCommand):
|
||||
"""Add one or more nodes in a single undoable step."""
|
||||
|
||||
def __init__(self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None) -> None:
|
||||
def __init__(
|
||||
self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None
|
||||
) -> None:
|
||||
super().__init__(vm, text or f"Add {len(nodes)} node(s)")
|
||||
self._nodes = list(nodes)
|
||||
|
||||
|
|
@ -42,14 +44,15 @@ class DeleteNodesCommand(ProjectCommand):
|
|||
def __init__(self, vm: "ProjectViewModel", node_ids: set[int]) -> None:
|
||||
super().__init__(vm, f"Delete {len(node_ids)} node(s)")
|
||||
self._node_ids = set(node_ids)
|
||||
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
|
||||
self._removed_elements: list[tuple[int, object]] = [] # (index, element)
|
||||
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
|
||||
self._removed_elements: list[tuple[int, object]] = [] # (index, element)
|
||||
self._removed_mp_constraints: list[tuple[int, object]] = []
|
||||
|
||||
def redo(self) -> None:
|
||||
# Cascade: snapshot every element that references a doomed node.
|
||||
self._removed_elements = [
|
||||
(i, el) for i, el in enumerate(self.project.elements)
|
||||
(i, el)
|
||||
for i, el in enumerate(self.project.elements)
|
||||
if any(nid in self._node_ids for nid in el.nodes)
|
||||
]
|
||||
doomed_elem_ids = {el.id for _, el in self._removed_elements}
|
||||
|
|
@ -58,7 +61,8 @@ class DeleteNodesCommand(ProjectCommand):
|
|||
]
|
||||
|
||||
self._removed_mp_constraints = [
|
||||
(i, mp) for i, mp in enumerate(self.project.mp_constraints)
|
||||
(i, mp)
|
||||
for i, mp in enumerate(self.project.mp_constraints)
|
||||
if mp.retained_node in self._node_ids or mp.constrained_node in self._node_ids
|
||||
]
|
||||
doomed_mp = {id(mp) for _, mp in self._removed_mp_constraints}
|
||||
|
|
@ -69,9 +73,7 @@ class DeleteNodesCommand(ProjectCommand):
|
|||
self._removed_nodes = [
|
||||
(i, n) for i, n in enumerate(self.project.nodes) if n.id in self._node_ids
|
||||
]
|
||||
self.project.nodes[:] = [
|
||||
n for n in self.project.nodes if n.id not in self._node_ids
|
||||
]
|
||||
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._node_ids]
|
||||
self._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ if TYPE_CHECKING:
|
|||
class AddSectionsCommand(ProjectCommand):
|
||||
"""Add one or more sections in a single undoable step."""
|
||||
|
||||
def __init__(self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None) -> None:
|
||||
def __init__(
|
||||
self, vm: "ProjectViewModel", sections: list[Any], *, text: str | None = None
|
||||
) -> None:
|
||||
super().__init__(vm, text or f"Add {len(sections)} section(s)")
|
||||
self._sections = list(sections)
|
||||
|
||||
|
|
@ -41,8 +43,7 @@ class DeleteSectionsCommand(ProjectCommand):
|
|||
|
||||
def redo(self) -> None:
|
||||
self._removed = [
|
||||
(i, s) for i, s in enumerate(self.project.sections)
|
||||
if s.id in self._section_ids
|
||||
(i, s) for i, s in enumerate(self.project.sections) if s.id in self._section_ids
|
||||
]
|
||||
self.project.sections[:] = [
|
||||
s for s in self.project.sections if s.id not in self._section_ids
|
||||
|
|
|
|||
|
|
@ -31,9 +31,7 @@ class AddTimeSeriesCommand(ProjectCommand):
|
|||
self._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
self.project.time_series[:] = [
|
||||
t for t in self.project.time_series if t.id != self._ts.id
|
||||
]
|
||||
self.project.time_series[:] = [t for t in self.project.time_series if t.id != self._ts.id]
|
||||
self._notify()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ class MoveNodesCommand(ProjectCommand):
|
|||
for i, n in enumerate(self.project.nodes):
|
||||
if n.id in self._node_ids:
|
||||
self._previous[n.id] = n.coords
|
||||
self.project.nodes[i] = n.model_copy(update={
|
||||
"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)
|
||||
})
|
||||
self.project.nodes[i] = n.model_copy(
|
||||
update={"coords": (n.coords[0] + dx, n.coords[1] + dy, n.coords[2] + dz)}
|
||||
)
|
||||
self._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
|
|
@ -84,7 +84,8 @@ class ReplicateCommand(ProjectCommand):
|
|||
# Snapshot the source nodes/elements once (won't change during redo).
|
||||
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
|
||||
src_elements = [
|
||||
e for e in self.project.elements
|
||||
e
|
||||
for e in self.project.elements
|
||||
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
|
||||
]
|
||||
next_node_id = self.project.next_node_id()
|
||||
|
|
@ -95,30 +96,34 @@ class ReplicateCommand(ProjectCommand):
|
|||
for k in range(1, self._n_copies + 1):
|
||||
mapping: dict[int, int] = {}
|
||||
for orig in src_nodes:
|
||||
new_node = orig.model_copy(update={
|
||||
"id": next_node_id,
|
||||
"coords": (orig.coords[0] + k * dx,
|
||||
orig.coords[1] + k * dy,
|
||||
orig.coords[2] + k * dz),
|
||||
})
|
||||
new_node = orig.model_copy(
|
||||
update={
|
||||
"id": next_node_id,
|
||||
"coords": (
|
||||
orig.coords[0] + k * dx,
|
||||
orig.coords[1] + k * dy,
|
||||
orig.coords[2] + k * dz,
|
||||
),
|
||||
}
|
||||
)
|
||||
self.project.nodes.append(new_node)
|
||||
self._added_node_ids.add(next_node_id)
|
||||
mapping[orig.id] = next_node_id
|
||||
next_node_id += 1
|
||||
for orig in src_elements:
|
||||
new_elem = orig.model_copy(update={
|
||||
"id": next_elem_id,
|
||||
"nodes": tuple(mapping[nid] for nid in orig.nodes),
|
||||
})
|
||||
new_elem = orig.model_copy(
|
||||
update={
|
||||
"id": next_elem_id,
|
||||
"nodes": tuple(mapping[nid] for nid in orig.nodes),
|
||||
}
|
||||
)
|
||||
self.project.elements.append(new_elem)
|
||||
self._added_element_ids.add(next_elem_id)
|
||||
next_elem_id += 1
|
||||
self._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
self.project.nodes[:] = [
|
||||
n for n in self.project.nodes if n.id not in self._added_node_ids
|
||||
]
|
||||
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
|
||||
self.project.elements[:] = [
|
||||
e for e in self.project.elements if e.id not in self._added_element_ids
|
||||
]
|
||||
|
|
@ -166,7 +171,8 @@ class MirrorCommand(ProjectCommand):
|
|||
def redo(self) -> None:
|
||||
src_nodes = [n for n in self.project.nodes if n.id in self._node_ids]
|
||||
src_elements = [
|
||||
e for e in self.project.elements
|
||||
e
|
||||
for e in self.project.elements
|
||||
if e.id in self._element_ids and all(nid in self._node_ids for nid in e.nodes)
|
||||
]
|
||||
next_node_id = self.project.next_node_id()
|
||||
|
|
@ -176,28 +182,30 @@ class MirrorCommand(ProjectCommand):
|
|||
mapping: dict[int, int] = {}
|
||||
|
||||
for orig in src_nodes:
|
||||
new_node = orig.model_copy(update={
|
||||
"id": next_node_id,
|
||||
"coords": self._reflect(orig.coords, self._plane),
|
||||
})
|
||||
new_node = orig.model_copy(
|
||||
update={
|
||||
"id": next_node_id,
|
||||
"coords": self._reflect(orig.coords, self._plane),
|
||||
}
|
||||
)
|
||||
self.project.nodes.append(new_node)
|
||||
self._added_node_ids.add(next_node_id)
|
||||
mapping[orig.id] = next_node_id
|
||||
next_node_id += 1
|
||||
for orig in src_elements:
|
||||
new_elem = orig.model_copy(update={
|
||||
"id": next_elem_id,
|
||||
"nodes": tuple(mapping[nid] for nid in orig.nodes),
|
||||
})
|
||||
new_elem = orig.model_copy(
|
||||
update={
|
||||
"id": next_elem_id,
|
||||
"nodes": tuple(mapping[nid] for nid in orig.nodes),
|
||||
}
|
||||
)
|
||||
self.project.elements.append(new_elem)
|
||||
self._added_element_ids.add(next_elem_id)
|
||||
next_elem_id += 1
|
||||
self._notify()
|
||||
|
||||
def undo(self) -> None:
|
||||
self.project.nodes[:] = [
|
||||
n for n in self.project.nodes if n.id not in self._added_node_ids
|
||||
]
|
||||
self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._added_node_ids]
|
||||
self.project.elements[:] = [
|
||||
e for e in self.project.elements if e.id not in self._added_element_ids
|
||||
]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ from otko.core.loads import (
|
|||
ConstantTimeSeries,
|
||||
ImposedSupportMotionPattern,
|
||||
LinearTimeSeries,
|
||||
LoadCombination,
|
||||
LoadCombinationItem,
|
||||
LoadPattern,
|
||||
NodalLoad,
|
||||
PathTimeSeries,
|
||||
|
|
@ -165,6 +167,8 @@ __all__ = [
|
|||
"PlainLoadPattern",
|
||||
"UniformExcitationPattern",
|
||||
"ImposedSupportMotionPattern",
|
||||
"LoadCombination",
|
||||
"LoadCombinationItem",
|
||||
"NodalLoad",
|
||||
"UniformElementLoad",
|
||||
"ResponseSpectrum",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ class Entity(BaseModel):
|
|||
"""Base class for every persisted domain object."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=False, # individual setters allowed; we lock at the Project boundary
|
||||
extra="forbid", # unknown JSON keys are an error, not a silent ignore
|
||||
frozen=False, # individual setters allowed; we lock at the Project boundary
|
||||
extra="forbid", # unknown JSON keys are an error, not a silent ignore
|
||||
validate_assignment=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.")
|
||||
id: PositiveInt = Field(
|
||||
..., description="Unique tag within its kind. Used as the OpenSees tag."
|
||||
)
|
||||
name: str = Field(default="", description="Optional human-readable label.")
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ class TransientCase(Entity):
|
|||
constraints: str = "Plain"
|
||||
integrator: str = "Newmark"
|
||||
integrator_params: tuple[float, float] = Field(
|
||||
default=(0.5, 0.25), description="Newmark gamma, beta (default = average acceleration).",
|
||||
default=(0.5, 0.25),
|
||||
description="Newmark gamma, beta (default = average acceleration).",
|
||||
)
|
||||
algorithm: str = "Newton"
|
||||
test: str = "NormDispIncr"
|
||||
|
|
@ -100,7 +101,7 @@ class TransientCase(Entity):
|
|||
rayleigh_beta_k: float = Field(
|
||||
default=0.0,
|
||||
description="Stiffness-proportional βK on CURRENT/tangent stiffness — "
|
||||
"``rayleigh`` slot 2 (damps high frequencies).",
|
||||
"``rayleigh`` slot 2 (damps high frequencies).",
|
||||
)
|
||||
rayleigh_beta_k_init: float = Field(
|
||||
default=0.0,
|
||||
|
|
@ -114,11 +115,11 @@ class TransientCase(Entity):
|
|||
)
|
||||
rayleigh_beta_k_comm: float = Field(
|
||||
default=0.0,
|
||||
description="Stiffness-proportional βK on COMMITTED stiffness — "
|
||||
"``rayleigh`` slot 4.",
|
||||
description="Stiffness-proportional βK on COMMITTED stiffness — " "``rayleigh`` slot 4.",
|
||||
)
|
||||
rayleigh_mode1_damping: float | None = Field(
|
||||
default=None, ge=0.0,
|
||||
default=None,
|
||||
ge=0.0,
|
||||
description=(
|
||||
"If set, βK is computed as 2·ζ/√λ₁ (first-mode eigenvalue) and "
|
||||
"overrides ``rayleigh_beta_k``. ``rayleigh_alpha_m`` still applies."
|
||||
|
|
@ -195,7 +196,7 @@ class PushoverCase(Entity):
|
|||
base_nodes: list[PositiveInt] = Field(
|
||||
default_factory=list,
|
||||
description="Nodes whose reactions sum into the 'base shear' for the curve. "
|
||||
"Leave empty to use every restrained node in the project.",
|
||||
"Leave empty to use every restrained node in the project.",
|
||||
)
|
||||
system: str = "BandGeneral"
|
||||
constraints: str = "Plain"
|
||||
|
|
@ -244,19 +245,26 @@ class ResponseSpectrumCase(Entity):
|
|||
|
||||
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
|
||||
modal_case_id: PositiveInt = Field(
|
||||
..., description="ID of the ModalCase whose mode shapes drive this analysis.",
|
||||
...,
|
||||
description="ID of the ModalCase whose mode shapes drive this analysis.",
|
||||
)
|
||||
spectrum_id: PositiveInt = Field(
|
||||
..., description="ID of the ResponseSpectrum to apply.",
|
||||
...,
|
||||
description="ID of the ResponseSpectrum to apply.",
|
||||
)
|
||||
direction: int = Field(
|
||||
..., ge=1, le=6, description="DOF direction (1..6) for the seismic excitation.",
|
||||
...,
|
||||
ge=1,
|
||||
le=6,
|
||||
description="DOF direction (1..6) for the seismic excitation.",
|
||||
)
|
||||
combination: Literal["SRSS", "CQC"] = "SRSS"
|
||||
damping_ratio: float | None = Field(
|
||||
default=None, ge=0.0, le=1.0,
|
||||
default=None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Override the spectrum's damping for CQC correlation. "
|
||||
"Defaults to the spectrum's damping_ratio.",
|
||||
"Defaults to the spectrum's damping_ratio.",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -159,12 +159,8 @@ def ensure_default_timeseries_and_pattern(
|
|||
if existing is not None:
|
||||
return existing
|
||||
pattern_name = name or DEFAULT_PATTERN_NAME
|
||||
ts = make_default_time_series(
|
||||
project.next_time_series_id(), kind=ts_kind, name=pattern_name
|
||||
)
|
||||
ts = make_default_time_series(project.next_time_series_id(), kind=ts_kind, name=pattern_name)
|
||||
project.time_series.append(ts)
|
||||
pattern = make_default_pattern(
|
||||
project.next_pattern_id(), ts.id, name=pattern_name
|
||||
)
|
||||
pattern = make_default_pattern(project.next_pattern_id(), ts.id, name=pattern_name)
|
||||
project.load_patterns.append(pattern)
|
||||
return pattern
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ class ZeroLengthElement(Entity):
|
|||
type: Literal["ZeroLength"] = "ZeroLength"
|
||||
nodes: tuple[PositiveInt, PositiveInt]
|
||||
material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1)
|
||||
dofs: tuple[int, ...] = Field(..., min_length=1, description="DOF directions, 1-indexed (1..6).")
|
||||
dofs: tuple[int, ...] = Field(
|
||||
..., min_length=1, description="DOF directions, 1-indexed (1..6)."
|
||||
)
|
||||
do_rayleigh: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
|
|
@ -121,7 +123,9 @@ class ZeroLengthSectionElement(Entity):
|
|||
|
||||
type: Literal["ZeroLengthSection"] = "ZeroLengthSection"
|
||||
nodes: tuple[PositiveInt, PositiveInt]
|
||||
section_id: PositiveInt = Field(..., description="Section attached to the two coincident nodes.")
|
||||
section_id: PositiveInt = Field(
|
||||
..., description="Section attached to the two coincident nodes."
|
||||
)
|
||||
|
||||
|
||||
class BeamWithHingesElement(Entity):
|
||||
|
|
@ -179,7 +183,8 @@ class QuadElement(Entity):
|
|||
description="Surface pressure applied over the element (force / area).",
|
||||
)
|
||||
rho: float = Field(
|
||||
default=0.0, ge=0.0,
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.",
|
||||
)
|
||||
b1: float = Field(
|
||||
|
|
|
|||
|
|
@ -87,10 +87,12 @@ class GridSystem(BaseModel):
|
|||
if isinstance(v, dict):
|
||||
records.append(v)
|
||||
else:
|
||||
records.append({
|
||||
"id": f"{prefix}{i + 1}",
|
||||
"ordinate": float(v),
|
||||
})
|
||||
records.append(
|
||||
{
|
||||
"id": f"{prefix}{i + 1}",
|
||||
"ordinate": float(v),
|
||||
}
|
||||
)
|
||||
data[grid_key] = records
|
||||
return data
|
||||
|
||||
|
|
@ -121,21 +123,21 @@ class GridSystem(BaseModel):
|
|||
|
||||
def bounds(self) -> tuple[tuple[float, float], tuple[float, float], tuple[float, float]]:
|
||||
"""Return ((xmin, xmax), (ymin, ymax), (zmin, zmax)) spanning the grid."""
|
||||
|
||||
def span(vs: list[float]) -> tuple[float, float]:
|
||||
if not vs:
|
||||
return (0.0, 0.0)
|
||||
return (vs[0], vs[-1])
|
||||
|
||||
return span(self.x_lines), span(self.y_lines), span(self.z_lines)
|
||||
|
||||
|
||||
def make_grid_lines(
|
||||
axis: Literal["X", "Y", "Z"], ordinates: list[float],
|
||||
axis: Literal["X", "Y", "Z"],
|
||||
ordinates: list[float],
|
||||
) -> list[GridLine]:
|
||||
"""Helper: build default-metadata GridLine records from flat ordinates."""
|
||||
return [
|
||||
GridLine(id=f"{axis}{i + 1}", ordinate=float(v))
|
||||
for i, v in enumerate(ordinates)
|
||||
]
|
||||
return [GridLine(id=f"{axis}{i + 1}", ordinate=float(v)) for i, v in enumerate(ordinates)]
|
||||
|
||||
|
||||
class CoordinateSystem(BaseModel):
|
||||
|
|
@ -160,9 +162,9 @@ class CoordinateSystem(BaseModel):
|
|||
cz, sz = math.cos(rz), math.sin(rz)
|
||||
# Rz · Ry · Rx (applied right-to-left: Rx first, then Ry, then Rz).
|
||||
return [
|
||||
[cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
|
||||
[cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
|
||||
[-sy, sx * cy, cx * cy],
|
||||
[cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz],
|
||||
[cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz],
|
||||
[-sy, sx * cy, cx * cy],
|
||||
]
|
||||
|
||||
def local_to_world(self, p_local: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt
|
||||
from pydantic import BaseModel, ConfigDict, Field, PositiveInt, model_validator
|
||||
|
||||
from otko.core._base import Entity
|
||||
|
||||
|
|
@ -77,15 +77,19 @@ class ResponseSpectrum(Entity):
|
|||
|
||||
type: Literal["ResponseSpectrum"] = "ResponseSpectrum"
|
||||
periods: list[float] = Field(
|
||||
..., min_length=2,
|
||||
...,
|
||||
min_length=2,
|
||||
description="Periods (s), strictly increasing.",
|
||||
)
|
||||
accelerations: list[float] = Field(
|
||||
..., min_length=2,
|
||||
...,
|
||||
min_length=2,
|
||||
description="Spectral pseudo-accelerations (length must match `periods`).",
|
||||
)
|
||||
damping_ratio: float = Field(
|
||||
default=0.05, ge=0.0, le=1.0,
|
||||
default=0.05,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Modal damping ratio the spectrum was built for.",
|
||||
)
|
||||
|
||||
|
|
@ -185,12 +189,66 @@ class ImposedSupportMotionPattern(Entity):
|
|||
direction: int = Field(..., ge=1, le=6, description="DOF direction (1..6).")
|
||||
disp_series_id: PositiveInt
|
||||
node_ids: list[int] = Field(
|
||||
..., min_length=1,
|
||||
...,
|
||||
min_length=1,
|
||||
description="Support nodes driven by the motion (each restrained in `direction`).",
|
||||
)
|
||||
factor: float = 1.0
|
||||
|
||||
|
||||
# ──────────────────────────── Load combinations ────────────────────────────
|
||||
class LoadCombinationItem(BaseModel):
|
||||
"""One term of a case-result load combination: ``factor × results(case_id)``."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
case_id: PositiveInt = Field(..., description="AnalysisCase whose results are scaled.")
|
||||
factor: float = Field(default=1.0, description="Scale applied to that case's results.")
|
||||
|
||||
|
||||
class LoadCombination(Entity):
|
||||
"""Named combination of *completed* analysis-case results (post-processing).
|
||||
|
||||
A ``Linear`` combination is the weighted sum
|
||||
``Σ factor_i × results(case_i)`` over nodal displacements, reactions,
|
||||
and element forces. Valid for linear-elastic static superposition
|
||||
(e.g. ``1.2×Dead + 1.6×Live``) — combining nonlinear, transient, or
|
||||
modal results is rejected at evaluation time, not here, so projects
|
||||
stay loadable while the UI guides the user.
|
||||
|
||||
An ``Envelope`` combination reports the per-DOF, per-step maximum and
|
||||
minimum across the factored terms instead of their sum.
|
||||
"""
|
||||
|
||||
type: Literal["LoadCombination"] = "LoadCombination"
|
||||
kind: Literal["Linear", "Envelope"] = Field(
|
||||
default="Linear", description="Linear = weighted sum; Envelope = per-DOF max/min."
|
||||
)
|
||||
items: list[LoadCombinationItem] = Field(
|
||||
..., min_length=1, description="Terms of the combination (≥1; ≥2 recommended)."
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_items(self) -> "LoadCombination":
|
||||
"""Reject duplicate case ids and non-finite factors."""
|
||||
import math
|
||||
|
||||
seen: set[int] = set()
|
||||
dup: set[int] = set()
|
||||
for item in self.items:
|
||||
if item.case_id in seen:
|
||||
dup.add(item.case_id)
|
||||
seen.add(item.case_id)
|
||||
if not math.isfinite(item.factor):
|
||||
raise ValueError(
|
||||
f"LoadCombination {self.id}: factor for case {item.case_id} "
|
||||
f"must be finite, got {item.factor!r}."
|
||||
)
|
||||
if dup:
|
||||
raise ValueError(f"LoadCombination {self.id}: duplicate case ids: {sorted(dup)}.")
|
||||
return self
|
||||
|
||||
|
||||
LoadPattern = Annotated[
|
||||
Union[PlainLoadPattern, UniformExcitationPattern, ImposedSupportMotionPattern],
|
||||
Field(discriminator="type"),
|
||||
|
|
|
|||
|
|
@ -58,7 +58,9 @@ class Steel02(Entity):
|
|||
Fy: PositiveFloat
|
||||
E0: PositiveFloat
|
||||
b: float = Field(..., ge=0.0, le=1.0)
|
||||
R0: float = Field(default=18.0, description="Bauschinger curvature parameter (typically 10–20).")
|
||||
R0: float = Field(
|
||||
default=18.0, description="Bauschinger curvature parameter (typically 10–20)."
|
||||
)
|
||||
cR1: float = Field(default=0.925)
|
||||
cR2: float = Field(default=0.15)
|
||||
|
||||
|
|
@ -83,7 +85,10 @@ class Concrete02(Entity):
|
|||
fpcu: float = Field(..., le=0.0)
|
||||
epsU: float = Field(..., lt=0.0)
|
||||
lambda_: float = Field(
|
||||
..., alias="lambda", ge=0.0, le=1.0,
|
||||
...,
|
||||
alias="lambda",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Ratio between unloading slope at epscu and initial slope.",
|
||||
)
|
||||
ft: PositiveFloat = Field(..., description="Tensile strength.")
|
||||
|
|
@ -108,15 +113,19 @@ class Concrete04(Entity):
|
|||
epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).")
|
||||
Ec: PositiveFloat = Field(..., description="Initial tangent modulus.")
|
||||
fct: float | None = Field(
|
||||
default=None, gt=0.0,
|
||||
default=None,
|
||||
gt=0.0,
|
||||
description="Maximum tensile strength. Omit for no-tension model.",
|
||||
)
|
||||
et: float | None = Field(
|
||||
default=None, gt=0.0,
|
||||
default=None,
|
||||
gt=0.0,
|
||||
description="Ultimate tensile strain. Required when fct is given.",
|
||||
)
|
||||
beta: float | None = Field(
|
||||
default=None, ge=0.0, le=1.0,
|
||||
default=None,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Cyclic degradation factor on unloading stiffness. Requires fct and et.",
|
||||
)
|
||||
|
||||
|
|
@ -138,7 +147,8 @@ class ElasticPP(Entity):
|
|||
E: PositiveFloat
|
||||
epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.")
|
||||
epsy_neg: float | None = Field(
|
||||
default=None, description="Yield strain in compression (negative); defaults to -epsy_pos.",
|
||||
default=None,
|
||||
description="Yield strain in compression (negative); defaults to -epsy_pos.",
|
||||
)
|
||||
eps0: float = Field(default=0.0, description="Initial strain.")
|
||||
|
||||
|
|
@ -176,7 +186,8 @@ class HystereticMaterial(Entity):
|
|||
d1: float = Field(default=0.0, ge=0.0, description="Ductility damage, linear portion.")
|
||||
d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.")
|
||||
beta: float = Field(
|
||||
default=0.0, ge=0.0,
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
description="Unloading-stiffness degradation (0 = no degradation).",
|
||||
)
|
||||
|
||||
|
|
@ -206,7 +217,8 @@ class HystereticSM(Entity):
|
|||
|
||||
type: Literal["HystereticSM"] = "HystereticSM"
|
||||
pos_env: list[tuple[float, float]] = Field(
|
||||
..., min_length=1,
|
||||
...,
|
||||
min_length=1,
|
||||
description="Positive envelope (force, deformation) pairs, force first.",
|
||||
)
|
||||
neg_env: list[tuple[float, float]] = Field(
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from otko.core.geometry import (
|
|||
Node,
|
||||
default_global_system,
|
||||
)
|
||||
from otko.core.loads import LoadPattern, ResponseSpectrum, TimeSeries
|
||||
from otko.core.loads import LoadCombination, LoadPattern, ResponseSpectrum, TimeSeries
|
||||
from otko.core.materials import Material
|
||||
from otko.core.sections import Section
|
||||
from otko.core.units import UnitSystem
|
||||
|
|
@ -110,6 +110,7 @@ _INDEX_KINDS: tuple[str, ...] = (
|
|||
"load_patterns",
|
||||
"spectra",
|
||||
"analyses",
|
||||
"combinations",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -155,11 +156,13 @@ class Project(BaseModel):
|
|||
"""
|
||||
if isinstance(data, dict) and "grid_system" in data and "coord_systems" not in data:
|
||||
legacy = data.pop("grid_system")
|
||||
data["coord_systems"] = [{
|
||||
"name": "Global",
|
||||
"coord": {},
|
||||
"grid": legacy,
|
||||
}]
|
||||
data["coord_systems"] = [
|
||||
{
|
||||
"name": "Global",
|
||||
"coord": {},
|
||||
"grid": legacy,
|
||||
}
|
||||
]
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -203,10 +206,12 @@ class Project(BaseModel):
|
|||
return
|
||||
# No Global system yet — create one with this grid.
|
||||
from otko.core.geometry import CoordinateGridSystem
|
||||
|
||||
self.coord_systems.insert(
|
||||
0,
|
||||
CoordinateGridSystem(name="Global", grid=new_grid),
|
||||
)
|
||||
|
||||
sections: list[Section] = Field(default_factory=list)
|
||||
elements: list[Element] = Field(default_factory=list)
|
||||
mp_constraints: list[EqualDOFConstraint] = Field(default_factory=list)
|
||||
|
|
@ -214,6 +219,7 @@ class Project(BaseModel):
|
|||
load_patterns: list[LoadPattern] = Field(default_factory=list)
|
||||
spectra: list[ResponseSpectrum] = Field(default_factory=list)
|
||||
analyses: list[AnalysisCase] = Field(default_factory=list)
|
||||
combinations: list[LoadCombination] = Field(default_factory=list)
|
||||
|
||||
# ─────────────────── invariants ───────────────────
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -236,6 +242,7 @@ class Project(BaseModel):
|
|||
("time series", self.time_series),
|
||||
("load pattern", self.load_patterns),
|
||||
("analysis", self.analyses),
|
||||
("combination", self.combinations),
|
||||
):
|
||||
ids = [it.id for it in items]
|
||||
if len(ids) != len(set(ids)):
|
||||
|
|
@ -270,6 +277,10 @@ class Project(BaseModel):
|
|||
def next_analysis_id(self) -> int:
|
||||
return self._next_id(self.analyses)
|
||||
|
||||
def next_combination_id(self) -> int:
|
||||
"""Return the next free load-combination id."""
|
||||
return self._next_id(self.combinations)
|
||||
|
||||
# ─────────────────── lookups ───────────────────
|
||||
def node(self, node_id: PositiveInt) -> Node:
|
||||
return cast(Node, self._lookup("nodes", self.nodes, node_id, "node"))
|
||||
|
|
@ -283,6 +294,20 @@ class Project(BaseModel):
|
|||
def element(self, element_id: PositiveInt) -> Element:
|
||||
return cast(Element, self._lookup("elements", self.elements, element_id, "element"))
|
||||
|
||||
def combination(self, combination_id: PositiveInt) -> LoadCombination:
|
||||
"""Return the load combination with ``combination_id``.
|
||||
|
||||
Args:
|
||||
combination_id: Combination tag to look up.
|
||||
|
||||
Raises:
|
||||
KeyError: if no combination carries that id.
|
||||
"""
|
||||
return cast(
|
||||
LoadCombination,
|
||||
self._lookup("combinations", self.combinations, combination_id, "combination"),
|
||||
)
|
||||
|
||||
def _lookup(self, kind: str, items: list[Any], target_id: int, label: str) -> Any:
|
||||
"""Return the entity with ``target_id`` from ``items`` in O(1) time.
|
||||
|
||||
|
|
@ -395,5 +420,14 @@ class Project(BaseModel):
|
|||
f"uses invalid DOF {dof} for ndf={self.ndf}."
|
||||
)
|
||||
|
||||
analysis_ids = {c.id for c in self.analyses}
|
||||
for combo in self.combinations:
|
||||
for item in combo.items:
|
||||
if item.case_id not in analysis_ids:
|
||||
problems.append(
|
||||
f"Combination {combo.id} refers to missing analysis case "
|
||||
f"{item.case_id}."
|
||||
)
|
||||
|
||||
if problems:
|
||||
raise ValueError("Reference validation failed:\n - " + "\n - ".join(problems))
|
||||
|
|
|
|||
|
|
@ -75,8 +75,12 @@ class ElasticSection(Entity):
|
|||
A: PositiveFloat
|
||||
Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.")
|
||||
Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.")
|
||||
G: PositiveFloat | None = Field(default=None, description="Shear modulus; required for 3D frames.")
|
||||
J: PositiveFloat | None = Field(default=None, description="Torsional constant; required for 3D frames.")
|
||||
G: PositiveFloat | None = Field(
|
||||
default=None, description="Shear modulus; required for 3D frames."
|
||||
)
|
||||
J: PositiveFloat | None = Field(
|
||||
default=None, description="Torsional constant; required for 3D frames."
|
||||
)
|
||||
shape: SectionShape | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
|
@ -198,7 +202,8 @@ class AggregatorDOF(BaseModel):
|
|||
|
||||
material_id: PositiveInt
|
||||
dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field(
|
||||
..., description="Section DOF code (OpenSees section-deformation names).",
|
||||
...,
|
||||
description="Section DOF code (OpenSees section-deformation names).",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -42,30 +42,46 @@ class UnitLabels:
|
|||
label axes without hard-coding any particular set of units.
|
||||
"""
|
||||
|
||||
length: str # "m", "mm", "in", "ft"
|
||||
force: str # "N", "kip"
|
||||
moment: str # "N·m", "kip·in"
|
||||
stress: str # "Pa", "MPa", "ksi", "ksf"
|
||||
curvature: str # "1/m", "1/in", …
|
||||
rotation: str # "rad" (always, no unit variants in practice)
|
||||
length: str # "m", "mm", "in", "ft"
|
||||
force: str # "N", "kip"
|
||||
moment: str # "N·m", "kip·in"
|
||||
stress: str # "Pa", "MPa", "ksi", "ksf"
|
||||
curvature: str # "1/m", "1/in", …
|
||||
rotation: str # "rad" (always, no unit variants in practice)
|
||||
|
||||
|
||||
_LABELS: dict[UnitSystem, UnitLabels] = {
|
||||
UnitSystem.SI_M_N: UnitLabels(
|
||||
length="m", force="N", moment="N·m",
|
||||
stress="Pa", curvature="1/m", rotation="rad",
|
||||
length="m",
|
||||
force="N",
|
||||
moment="N·m",
|
||||
stress="Pa",
|
||||
curvature="1/m",
|
||||
rotation="rad",
|
||||
),
|
||||
UnitSystem.SI_MM_N: UnitLabels(
|
||||
length="mm", force="N", moment="N·mm",
|
||||
stress="MPa", curvature="1/mm", rotation="rad",
|
||||
length="mm",
|
||||
force="N",
|
||||
moment="N·mm",
|
||||
stress="MPa",
|
||||
curvature="1/mm",
|
||||
rotation="rad",
|
||||
),
|
||||
UnitSystem.US_FT_KIP: UnitLabels(
|
||||
length="ft", force="kip", moment="kip·ft",
|
||||
stress="ksf", curvature="1/ft", rotation="rad",
|
||||
length="ft",
|
||||
force="kip",
|
||||
moment="kip·ft",
|
||||
stress="ksf",
|
||||
curvature="1/ft",
|
||||
rotation="rad",
|
||||
),
|
||||
UnitSystem.US_IN_KIP: UnitLabels(
|
||||
length="in", force="kip", moment="kip·in",
|
||||
stress="ksi", curvature="1/in", rotation="rad",
|
||||
length="in",
|
||||
force="kip",
|
||||
moment="kip·in",
|
||||
stress="ksi",
|
||||
curvature="1/in",
|
||||
rotation="rad",
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ worker lives in ``qt_workers`` and is the only module here that
|
|||
imports PySide6.
|
||||
"""
|
||||
|
||||
from otko.services.combinations import (
|
||||
EnvelopeResults,
|
||||
combine_static_results,
|
||||
envelope_static_results,
|
||||
evaluate_combination,
|
||||
)
|
||||
from otko.services.export import export_opspy, export_tcl
|
||||
from otko.services.material_tester import (
|
||||
CyclicSegment,
|
||||
|
|
@ -28,12 +34,16 @@ from otko.services.results import (
|
|||
__all__ = [
|
||||
"PROJECT_FILE_SUFFIX",
|
||||
"CyclicSegment",
|
||||
"EnvelopeResults",
|
||||
"LoadProtocol",
|
||||
"MaterialTestResult",
|
||||
"ModalResults",
|
||||
"OpenSeesRunner",
|
||||
"StaticResults",
|
||||
"TransientResults",
|
||||
"combine_static_results",
|
||||
"envelope_static_results",
|
||||
"evaluate_combination",
|
||||
"export_opspy",
|
||||
"export_tcl",
|
||||
"load_project",
|
||||
|
|
|
|||
|
|
@ -409,7 +409,10 @@ class OpenSeesEmitter:
|
|||
if el.consistent_mass:
|
||||
ebc_args.append("-cMass")
|
||||
ops.element(
|
||||
"elasticBeamColumn", el.id, *el.nodes, *ebc_args,
|
||||
"elasticBeamColumn",
|
||||
el.id,
|
||||
*el.nodes,
|
||||
*ebc_args,
|
||||
)
|
||||
case ForceBeamColumn():
|
||||
tag = self._element_geom_transf_tag[el.id]
|
||||
|
|
@ -430,9 +433,13 @@ class OpenSeesEmitter:
|
|||
ops.element("dispBeamColumn", el.id, *el.nodes, tag, el.id)
|
||||
case ZeroLengthElement():
|
||||
zl_args: list[Any] = [
|
||||
"zeroLength", el.id, *el.nodes,
|
||||
"-mat", *el.material_ids,
|
||||
"-dir", *el.dofs,
|
||||
"zeroLength",
|
||||
el.id,
|
||||
*el.nodes,
|
||||
"-mat",
|
||||
*el.material_ids,
|
||||
"-dir",
|
||||
*el.dofs,
|
||||
]
|
||||
# ``-doRayleigh 1`` only when requested (default off, matching
|
||||
# OpenSees' zeroLength default) — so an isolator can opt its
|
||||
|
|
@ -647,15 +654,27 @@ class OpenSeesEmitter:
|
|||
)
|
||||
vel = np.gradient(np.asarray(ts.values, dtype=float), ts.dt)
|
||||
ops.timeSeries(
|
||||
"Path", vel_tag, "-dt", ts.dt, "-values", *vel.tolist(),
|
||||
"-factor", ts.factor,
|
||||
"Path",
|
||||
vel_tag,
|
||||
"-dt",
|
||||
ts.dt,
|
||||
"-values",
|
||||
*vel.tolist(),
|
||||
"-factor",
|
||||
ts.factor,
|
||||
)
|
||||
for nid in pat.node_ids:
|
||||
ops.remove("sp", nid, pat.direction)
|
||||
ops.pattern("MultipleSupport", pat.id)
|
||||
ops.groundMotion(
|
||||
pat.id, "Plain", "-disp", pat.disp_series_id, "-vel", vel_tag,
|
||||
"-fact", pat.factor,
|
||||
pat.id,
|
||||
"Plain",
|
||||
"-disp",
|
||||
pat.disp_series_id,
|
||||
"-vel",
|
||||
vel_tag,
|
||||
"-fact",
|
||||
pat.factor,
|
||||
)
|
||||
for nid in pat.node_ids:
|
||||
ops.imposedMotion(nid, pat.direction, pat.id)
|
||||
|
|
|
|||
|
|
@ -55,8 +55,7 @@ def export_mode_shape_video(
|
|||
else:
|
||||
# MP4 / WebM use FFmpeg backend. macro_block_size=1 disables
|
||||
# the auto-resize-for-h264 pass (still works on modern players).
|
||||
iio.imwrite(output_path, frames, fps=fps, codec="libx264",
|
||||
macro_block_size=1)
|
||||
iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
|
||||
|
||||
|
||||
def export_time_history_video(
|
||||
|
|
@ -94,5 +93,4 @@ def export_time_history_video(
|
|||
if suffix == ".gif":
|
||||
iio.imwrite(output_path, frames, duration=1.0 / fps, loop=0)
|
||||
else:
|
||||
iio.imwrite(output_path, frames, fps=fps, codec="libx264",
|
||||
macro_block_size=1)
|
||||
iio.imwrite(output_path, frames, fps=fps, codec="libx264", macro_block_size=1)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class DeformationSource:
|
|||
displacements to PyVista point arrays.
|
||||
"""
|
||||
|
||||
displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
|
||||
displacements: np.ndarray # shape (n_nodes, 3) — x, y, z components
|
||||
node_id_to_row: dict[int, int]
|
||||
scale: float = 1.0
|
||||
|
||||
|
|
@ -38,8 +38,11 @@ class DeformationSource:
|
|||
|
||||
|
||||
def static_to_deformation(
|
||||
project: Project, results: StaticResults, *,
|
||||
step: int = -1, scale: float = 1.0,
|
||||
project: Project,
|
||||
results: StaticResults,
|
||||
*,
|
||||
step: int = -1,
|
||||
scale: float = 1.0,
|
||||
) -> DeformationSource:
|
||||
"""Build a DeformationSource from a static analysis's nodal displacements.
|
||||
|
||||
|
|
@ -57,13 +60,16 @@ def static_to_deformation(
|
|||
# Take only translation DOFs (first 2 in 2D, first 3 in 3D).
|
||||
n_take = min(3, snapshot.shape[0])
|
||||
disp[node_id_to_row[nid], :n_take] = snapshot[:n_take]
|
||||
return DeformationSource(displacements=disp,
|
||||
node_id_to_row=node_id_to_row, scale=scale)
|
||||
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
|
||||
|
||||
|
||||
def modal_to_deformation(
|
||||
project: Project, results: ModalResults, *,
|
||||
mode: int = 0, scale: float = 1.0, phase: float = 1.0,
|
||||
project: Project,
|
||||
results: ModalResults,
|
||||
*,
|
||||
mode: int = 0,
|
||||
scale: float = 1.0,
|
||||
phase: float = 1.0,
|
||||
) -> DeformationSource:
|
||||
"""Build a DeformationSource from a modal analysis's mode shape.
|
||||
|
||||
|
|
@ -78,10 +84,9 @@ def modal_to_deformation(
|
|||
|
||||
mode_number = mode + 1 # mode_shapes is 1-indexed
|
||||
if mode_number not in results.mode_shapes:
|
||||
return DeformationSource(displacements=disp,
|
||||
node_id_to_row=node_id_to_row, scale=scale)
|
||||
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
|
||||
|
||||
eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
|
||||
eigvec = results.mode_shapes[mode_number] # dict: nid → np.ndarray of DOF values
|
||||
for nid, vec in eigvec.items():
|
||||
if nid not in node_id_to_row:
|
||||
continue
|
||||
|
|
@ -101,12 +106,15 @@ def modal_to_deformation(
|
|||
norm_factor = (bbox * 0.05) / max_amp
|
||||
disp *= norm_factor
|
||||
|
||||
return DeformationSource(displacements=disp,
|
||||
node_id_to_row=node_id_to_row, scale=scale)
|
||||
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
|
||||
|
||||
|
||||
def transient_to_deformation_at_step(
|
||||
project: Project, results, *, step: int = 0, scale: float = 1.0,
|
||||
project: Project,
|
||||
results,
|
||||
*,
|
||||
step: int = 0,
|
||||
scale: float = 1.0,
|
||||
) -> DeformationSource:
|
||||
"""Build a DeformationSource from a transient analysis at one step.
|
||||
|
||||
|
|
@ -139,8 +147,7 @@ def transient_to_deformation_at_step(
|
|||
norm_factor = (bbox * 0.10) / max_amp
|
||||
disp *= norm_factor
|
||||
|
||||
return DeformationSource(displacements=disp,
|
||||
node_id_to_row=node_id_to_row, scale=scale)
|
||||
return DeformationSource(displacements=disp, node_id_to_row=node_id_to_row, scale=scale)
|
||||
|
||||
|
||||
def linear_static_auto_scale(project: Project, results: StaticResults) -> float:
|
||||
|
|
|
|||
|
|
@ -41,27 +41,36 @@ from otko.services.results import StaticResults
|
|||
class ForceComponent(Enum):
|
||||
"""Which force component to plot."""
|
||||
|
||||
N = "N" # axial
|
||||
V2 = "V2" # shear in local y (in-plane shear for 2D)
|
||||
V3 = "V3" # shear in local z
|
||||
T = "T" # torsion
|
||||
M2 = "M2" # moment about local y
|
||||
M3 = "M3" # moment about local z (in-plane moment for 2D)
|
||||
N = "N" # axial
|
||||
V2 = "V2" # shear in local y (in-plane shear for 2D)
|
||||
V3 = "V3" # shear in local z
|
||||
T = "T" # torsion
|
||||
M2 = "M2" # moment about local y
|
||||
M3 = "M3" # moment about local z (in-plane moment for 2D)
|
||||
|
||||
|
||||
# Map (component, end) → index into the local-force vector for 3D and 2D.
|
||||
_INDEX_3D = {
|
||||
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 6,
|
||||
(ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 7,
|
||||
(ForceComponent.V3, "i"): 2, (ForceComponent.V3, "j"): 8,
|
||||
(ForceComponent.T, "i"): 3, (ForceComponent.T, "j"): 9,
|
||||
(ForceComponent.M2, "i"): 4, (ForceComponent.M2, "j"): 10,
|
||||
(ForceComponent.M3, "i"): 5, (ForceComponent.M3, "j"): 11,
|
||||
(ForceComponent.N, "i"): 0,
|
||||
(ForceComponent.N, "j"): 6,
|
||||
(ForceComponent.V2, "i"): 1,
|
||||
(ForceComponent.V2, "j"): 7,
|
||||
(ForceComponent.V3, "i"): 2,
|
||||
(ForceComponent.V3, "j"): 8,
|
||||
(ForceComponent.T, "i"): 3,
|
||||
(ForceComponent.T, "j"): 9,
|
||||
(ForceComponent.M2, "i"): 4,
|
||||
(ForceComponent.M2, "j"): 10,
|
||||
(ForceComponent.M3, "i"): 5,
|
||||
(ForceComponent.M3, "j"): 11,
|
||||
}
|
||||
_INDEX_2D = {
|
||||
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
|
||||
(ForceComponent.V2, "i"): 1, (ForceComponent.V2, "j"): 4,
|
||||
(ForceComponent.M3, "i"): 2, (ForceComponent.M3, "j"): 5,
|
||||
(ForceComponent.N, "i"): 0,
|
||||
(ForceComponent.N, "j"): 3,
|
||||
(ForceComponent.V2, "i"): 1,
|
||||
(ForceComponent.V2, "j"): 4,
|
||||
(ForceComponent.M3, "i"): 2,
|
||||
(ForceComponent.M3, "j"): 5,
|
||||
}
|
||||
|
||||
# Truss elements expose a different localForce layout than frames:
|
||||
|
|
@ -69,10 +78,12 @@ _INDEX_2D = {
|
|||
# 3D truss → 6-vector [N_i, 0, 0, N_j, 0, 0]
|
||||
# So we map only the N component; other components return None.
|
||||
_INDEX_TRUSS_2D = {
|
||||
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 2,
|
||||
(ForceComponent.N, "i"): 0,
|
||||
(ForceComponent.N, "j"): 2,
|
||||
}
|
||||
_INDEX_TRUSS_3D = {
|
||||
(ForceComponent.N, "i"): 0, (ForceComponent.N, "j"): 3,
|
||||
(ForceComponent.N, "i"): 0,
|
||||
(ForceComponent.N, "j"): 3,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -122,6 +133,7 @@ def extract_diagram_data(
|
|||
|
||||
# Local import to avoid a cycle (element classes live in core.geometry).
|
||||
from otko.core import CorotTrussElement, TrussElement
|
||||
|
||||
truss_types = (TrussElement, CorotTrussElement)
|
||||
|
||||
for el in project.elements:
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from __future__ import annotations
|
|||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from otko import __version__
|
||||
from otko._const import OPENSEESPY_VERSION, __version__
|
||||
from otko.core import (
|
||||
ModalCase,
|
||||
Project,
|
||||
|
|
@ -49,7 +49,7 @@ from otko.services.opensees_runner import OpenSeesRunner
|
|||
__all__ = ["RecordingOps", "export_opspy", "export_tcl"]
|
||||
|
||||
#: Pinned solver version written into every script header.
|
||||
PINNED_OPENSEESPY = "openseespy==3.8.0.0"
|
||||
PINNED_OPENSEESPY = f"openseespy=={OPENSEESPY_VERSION}"
|
||||
|
||||
#: Recorded call triple: (ops method name, positional args, keyword args).
|
||||
RecordedCall = tuple[str, tuple[Any, ...], dict[str, Any]]
|
||||
|
|
@ -362,7 +362,7 @@ def export_opspy(project: Project, case_id: int | None = None) -> str:
|
|||
are appended after the model.
|
||||
|
||||
Returns:
|
||||
The script source. The header pins ``openseespy==3.8.0.0``,
|
||||
The script source. The header pins the solver version,
|
||||
the OTKO version and the display units.
|
||||
|
||||
Raises:
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ class CyclicSegment(BaseModel):
|
|||
current -> *compressive_peak* -> *tensile_peak* -> 0.
|
||||
"""
|
||||
|
||||
compressive_peak: float = Field(..., lt=0.0, description="Target compressive strain (negative).")
|
||||
compressive_peak: float = Field(
|
||||
..., lt=0.0, description="Target compressive strain (negative)."
|
||||
)
|
||||
tensile_peak: float = Field(..., gt=0.0, description="Target tensile strain (positive).")
|
||||
n_cycles: PositiveInt = Field(1, description="Number of complete excursions to run.")
|
||||
|
||||
|
|
@ -160,17 +162,35 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
|
|||
ops.uniaxialMaterial("Steel01", mat.id, *args)
|
||||
case Steel02():
|
||||
ops.uniaxialMaterial(
|
||||
"Steel02", mat.id, mat.Fy, mat.E0, mat.b, mat.R0, mat.cR1, mat.cR2,
|
||||
"Steel02",
|
||||
mat.id,
|
||||
mat.Fy,
|
||||
mat.E0,
|
||||
mat.b,
|
||||
mat.R0,
|
||||
mat.cR1,
|
||||
mat.cR2,
|
||||
)
|
||||
case Concrete01():
|
||||
ops.uniaxialMaterial(
|
||||
"Concrete01", mat.id, mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
|
||||
"Concrete01",
|
||||
mat.id,
|
||||
mat.fpc,
|
||||
mat.epsc0,
|
||||
mat.fpcu,
|
||||
mat.epsU,
|
||||
)
|
||||
case Concrete02():
|
||||
ops.uniaxialMaterial(
|
||||
"Concrete02", mat.id,
|
||||
mat.fpc, mat.epsc0, mat.fpcu, mat.epsU,
|
||||
mat.lambda_, mat.ft, mat.Ets,
|
||||
"Concrete02",
|
||||
mat.id,
|
||||
mat.fpc,
|
||||
mat.epsc0,
|
||||
mat.fpcu,
|
||||
mat.epsU,
|
||||
mat.lambda_,
|
||||
mat.ft,
|
||||
mat.Ets,
|
||||
)
|
||||
case Concrete04():
|
||||
args = [mat.fpc, mat.epsc0, mat.epscu, mat.Ec]
|
||||
|
|
@ -187,10 +207,25 @@ def _emit_uniaxial(ops: Any, mat: Any) -> None:
|
|||
ops.uniaxialMaterial("ElasticPP", mat.id, *args)
|
||||
case HystereticMaterial():
|
||||
ops.uniaxialMaterial(
|
||||
"Hysteretic", mat.id,
|
||||
mat.s1p, mat.e1p, mat.s2p, mat.e2p, mat.s3p, mat.e3p,
|
||||
mat.s1n, mat.e1n, mat.s2n, mat.e2n, mat.s3n, mat.e3n,
|
||||
mat.px, mat.py, mat.d1, mat.d2, mat.beta,
|
||||
"Hysteretic",
|
||||
mat.id,
|
||||
mat.s1p,
|
||||
mat.e1p,
|
||||
mat.s2p,
|
||||
mat.e2p,
|
||||
mat.s3p,
|
||||
mat.e3p,
|
||||
mat.s1n,
|
||||
mat.e1n,
|
||||
mat.s2n,
|
||||
mat.e2n,
|
||||
mat.s3n,
|
||||
mat.e3n,
|
||||
mat.px,
|
||||
mat.py,
|
||||
mat.d1,
|
||||
mat.d2,
|
||||
mat.beta,
|
||||
)
|
||||
case _:
|
||||
raise TypeError(f"Unsupported material type: {type(mat).__name__}")
|
||||
|
|
@ -239,10 +274,13 @@ def test_uniaxial_material(
|
|||
"""
|
||||
if ops_module is None:
|
||||
import openseespy.opensees as _ops_default
|
||||
|
||||
ops_module = _ops_default
|
||||
|
||||
ops = ops_module
|
||||
mat_name = getattr(material, "name", None) or str(getattr(material, "type", type(material).__name__))
|
||||
mat_name = getattr(material, "name", None) or str(
|
||||
getattr(material, "type", type(material).__name__)
|
||||
)
|
||||
|
||||
try:
|
||||
# Clear any leftover OpenSees state from a prior call or runner.
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
|
|||
break
|
||||
# New NGA format: "3930 0.00500 NPTS, DT"
|
||||
m_new = re.match(
|
||||
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", stripped,
|
||||
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT",
|
||||
stripped,
|
||||
)
|
||||
if m_new:
|
||||
npts = int(m_new.group(1))
|
||||
|
|
@ -66,7 +67,7 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
|
|||
try:
|
||||
values.append(float(tok))
|
||||
except ValueError:
|
||||
pass # skip stray tokens
|
||||
pass # skip stray tokens
|
||||
if not values:
|
||||
raise ValueError("Header parsed but no numeric data lines found.")
|
||||
return dt, npts, values
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ class AnalysisWorker(QObject):
|
|||
|
||||
started = Signal()
|
||||
log = Signal(str)
|
||||
finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
|
||||
failed = Signal(str) # human-readable error message + traceback
|
||||
finished = Signal(object) # emits StaticResults / ModalResults / TransientResults
|
||||
failed = Signal(str) # human-readable error message + traceback
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -52,8 +52,10 @@ class AnalysisWorker(QObject):
|
|||
"""Slot to be invoked by ``QThread.started``."""
|
||||
self.started.emit()
|
||||
try:
|
||||
self.log.emit(f"Building model: {len(self._project.nodes)} nodes, "
|
||||
f"{len(self._project.elements)} elements.")
|
||||
self.log.emit(
|
||||
f"Building model: {len(self._project.nodes)} nodes, "
|
||||
f"{len(self._project.elements)} elements."
|
||||
)
|
||||
runner = OpenSeesRunner(self._project)
|
||||
self.log.emit(f"Running case '{self._case.name}' ({type(self._case).__name__}) ...")
|
||||
results = runner.run(self._case, results_dir=self._results_dir)
|
||||
|
|
|
|||
|
|
@ -56,8 +56,8 @@ class PushoverResults:
|
|||
n_steps: int
|
||||
control_node: int
|
||||
control_dof: int
|
||||
control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
|
||||
base_shear: np.ndarray # shape (n_steps + 1,), signed
|
||||
control_disp: np.ndarray # shape (n_steps + 1,), includes t=0
|
||||
base_shear: np.ndarray # shape (n_steps + 1,), signed
|
||||
node_disp: dict[int, np.ndarray] = field(default_factory=dict)
|
||||
"""node_id → shape (n_steps + 1, ndf) displacement history."""
|
||||
element_forces: dict[int, np.ndarray] = field(default_factory=dict)
|
||||
|
|
@ -158,7 +158,7 @@ class ResponseSpectrumResults:
|
|||
case_id: int
|
||||
case_name: str
|
||||
direction: int
|
||||
combination: str # "SRSS" or "CQC"
|
||||
combination: str # "SRSS" or "CQC"
|
||||
combined_disp: dict[int, np.ndarray] = field(default_factory=dict)
|
||||
"""node_id → 3-vector of peak combined translational displacements."""
|
||||
modes: list = field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -52,8 +52,10 @@ def _angle_in_arc(angle: float, start: float, span: float) -> bool:
|
|||
return ((angle - start) % 360.0) <= span + 1e-9
|
||||
|
||||
|
||||
def bbox_for_section(section: object, project: "Project | None" = None,
|
||||
) -> tuple[float, float] | None:
|
||||
def bbox_for_section(
|
||||
section: object,
|
||||
project: "Project | None" = None,
|
||||
) -> tuple[float, float] | None:
|
||||
"""Return ``(width_y, height_z)`` of the section's local bounding box.
|
||||
|
||||
``None`` means "size could not be inferred" — the caller should skip
|
||||
|
|
@ -69,6 +71,7 @@ def bbox_for_section(section: object, project: "Project | None" = None,
|
|||
SectionAggregator,
|
||||
StraightLayer,
|
||||
)
|
||||
|
||||
# ── ElasticSection: prefer explicit display dims; else back-solve.
|
||||
if isinstance(section, ElasticSection):
|
||||
explicit = _elastic_explicit_dims(section)
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ class SectionProps:
|
|||
area: float
|
||||
centroid_y: float
|
||||
centroid_z: float
|
||||
Iy: float # about centroid
|
||||
Iz: float # about centroid
|
||||
fibre_yz: np.ndarray # (n, 3): y, z, area
|
||||
Iy: float # about centroid
|
||||
Iz: float # about centroid
|
||||
fibre_yz: np.ndarray # (n, 3): y, z, area
|
||||
|
||||
|
||||
def expand_fibres(sec: FiberSection) -> np.ndarray:
|
||||
|
|
@ -65,9 +65,11 @@ def expand_fibres(sec: FiberSection) -> np.ndarray:
|
|||
yc = p.y_center + r_mid * math.cos(theta)
|
||||
zc = p.z_center + r_mid * math.sin(theta)
|
||||
# Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad)
|
||||
a = ((p.r_inner + (ir + 1) * d_r) ** 2
|
||||
- (p.r_inner + ir * d_r) ** 2) \
|
||||
* math.radians(d_theta) / 2.0
|
||||
a = (
|
||||
((p.r_inner + (ir + 1) * d_r) ** 2 - (p.r_inner + ir * d_r) ** 2)
|
||||
* math.radians(d_theta)
|
||||
/ 2.0
|
||||
)
|
||||
rows.append((yc, zc, a))
|
||||
|
||||
for lay in sec.layers:
|
||||
|
|
@ -102,8 +104,8 @@ def compute_section_props(sec: FiberSection) -> SectionProps:
|
|||
zc = float(np.sum(a * z) / total_a)
|
||||
|
||||
# Second moments of area about centroid (parallel axis from each fibre).
|
||||
Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
|
||||
Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
|
||||
Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
|
||||
Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
|
||||
|
||||
return SectionProps(
|
||||
n_fibres=len(fibres),
|
||||
|
|
|
|||
|
|
@ -24,20 +24,22 @@ from otko.services.results import ModalResults
|
|||
class ModeContribution:
|
||||
"""Per-mode metadata for a response-spectrum analysis."""
|
||||
|
||||
mode_number: int # 1-indexed
|
||||
period: float # s
|
||||
frequency: float # Hz
|
||||
angular_frequency: float # rad/s
|
||||
participation_factor: float # Γ_i for the chosen direction
|
||||
effective_mass: float # M_eff,i = Γ_i² · M_i
|
||||
mass_ratio: float # M_eff,i / Σ m
|
||||
sa_at_period: float # Sa(T_i) from spectrum
|
||||
mode_number: int # 1-indexed
|
||||
period: float # s
|
||||
frequency: float # Hz
|
||||
angular_frequency: float # rad/s
|
||||
participation_factor: float # Γ_i for the chosen direction
|
||||
effective_mass: float # M_eff,i = Γ_i² · M_i
|
||||
mass_ratio: float # M_eff,i / Σ m
|
||||
sa_at_period: float # Sa(T_i) from spectrum
|
||||
modal_peak_disp: dict[int, np.ndarray] = field(default_factory=dict)
|
||||
"""node_id → peak modal displacement vector (3D translations)."""
|
||||
|
||||
|
||||
def mass_participation(
|
||||
project: Project, modal: ModalResults, direction: int,
|
||||
project: Project,
|
||||
modal: ModalResults,
|
||||
direction: int,
|
||||
) -> list[ModeContribution]:
|
||||
"""Compute Γ_i, M_eff,i and frequency for every mode.
|
||||
|
||||
|
|
@ -79,21 +81,23 @@ def mass_participation(
|
|||
m_eff = 0.0
|
||||
else:
|
||||
gamma = numerator / denominator
|
||||
m_eff = gamma ** 2 * denominator
|
||||
m_eff = gamma**2 * denominator
|
||||
|
||||
omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1])))
|
||||
period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf")
|
||||
ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0
|
||||
out.append(ModeContribution(
|
||||
mode_number=mode_number,
|
||||
period=period,
|
||||
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
|
||||
angular_frequency=omega,
|
||||
participation_factor=gamma,
|
||||
effective_mass=m_eff,
|
||||
mass_ratio=ratio,
|
||||
sa_at_period=0.0, # filled in by combine_spectrum
|
||||
))
|
||||
out.append(
|
||||
ModeContribution(
|
||||
mode_number=mode_number,
|
||||
period=period,
|
||||
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
|
||||
angular_frequency=omega,
|
||||
participation_factor=gamma,
|
||||
effective_mass=m_eff,
|
||||
mass_ratio=ratio,
|
||||
sa_at_period=0.0, # filled in by combine_spectrum
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -137,8 +141,7 @@ def combine_modal_response(
|
|||
m.sa_at_period = 0.0
|
||||
continue
|
||||
m.sa_at_period = interp_sa(spectrum, m.period)
|
||||
scale = (m.participation_factor * m.sa_at_period
|
||||
/ (m.angular_frequency ** 2))
|
||||
scale = m.participation_factor * m.sa_at_period / (m.angular_frequency**2)
|
||||
shape = modal.mode_shapes[m.mode_number]
|
||||
for nid, vec in shape.items():
|
||||
n_take = min(3, vec.size)
|
||||
|
|
@ -156,7 +159,7 @@ def combine_modal_response(
|
|||
for m in modes:
|
||||
u = m.modal_peak_disp.get(nid)
|
||||
if u is not None:
|
||||
sq_sum += u ** 2
|
||||
sq_sum += u**2
|
||||
combined[nid] = np.sqrt(sq_sum)
|
||||
elif method.upper() == "CQC":
|
||||
zeta = damping if damping is not None else spectrum.damping_ratio
|
||||
|
|
@ -169,8 +172,8 @@ def combine_modal_response(
|
|||
if wi <= 0.0 or wj <= 0.0:
|
||||
continue
|
||||
r = wj / wi
|
||||
num = 8.0 * zeta ** 2 * (1.0 + r) * r ** 1.5
|
||||
denom = (1.0 - r ** 2) ** 2 + 4.0 * zeta ** 2 * r * (1.0 + r) ** 2
|
||||
num = 8.0 * zeta**2 * (1.0 + r) * r**1.5
|
||||
denom = (1.0 - r**2) ** 2 + 4.0 * zeta**2 * r * (1.0 + r) ** 2
|
||||
rho[i, j] = num / denom if denom > 0.0 else 0.0
|
||||
for nid in node_ids:
|
||||
sq_sum = np.zeros(3)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class AnalysisRunner(QObject):
|
|||
|
||||
started = Signal()
|
||||
log = Signal(str)
|
||||
finished = Signal(object) # StaticResults / ModalResults / TransientResults
|
||||
finished = Signal(object) # StaticResults / ModalResults / TransientResults
|
||||
failed = Signal(str)
|
||||
runningChanged = Signal(bool)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ from otko.services import load_project, save_project
|
|||
class ProjectViewModel(QObject):
|
||||
"""Holds the current Project, its file path, dirty state, and undo stack."""
|
||||
|
||||
projectChanged = Signal(object) # emits Project | None
|
||||
modelMutated = Signal() # same project, mutated by a command
|
||||
projectChanged = Signal(object) # emits Project | None
|
||||
modelMutated = Signal() # same project, mutated by a command
|
||||
dirtyChanged = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from otko.views.dialogs import (
|
|||
AssignSectionDialog,
|
||||
AssignSupportDialog,
|
||||
AssignZeroLengthSectionDialog,
|
||||
LoadCombinationManagerDialog,
|
||||
CoordinateGridSystemsDialog,
|
||||
LinearTimeSeriesDialog,
|
||||
MaterialLibraryDialog,
|
||||
|
|
@ -917,6 +918,11 @@ class ActionHandlers:
|
|||
self._on_new()
|
||||
AnalysisCaseManagerDialog(self._vm, self).exec()
|
||||
|
||||
def _on_combo_manager(self) -> None:
|
||||
if self._vm.project is None:
|
||||
self._on_new()
|
||||
LoadCombinationManagerDialog(self._vm, self).exec()
|
||||
|
||||
def _on_run_analysis(self) -> None:
|
||||
if self._vm.project is None:
|
||||
QMessageBox.information(self, "Run Analysis", "Open or create a project first.")
|
||||
|
|
@ -961,10 +967,7 @@ class ActionHandlers:
|
|||
def _on_about(self) -> None:
|
||||
box = QMessageBox(self)
|
||||
box.setWindowTitle("About OTKO")
|
||||
box.setText(
|
||||
f"<h3>OTKO {__version__}</h3>"
|
||||
"<p>A modern desktop GUI for OpenSeesPy.</p>"
|
||||
)
|
||||
box.setText(f"<h3>OTKO {__version__}</h3>" "<p>A modern desktop GUI for OpenSeesPy.</p>")
|
||||
app_svg = Path(__file__).resolve().parent.parent / "resources" / "icons" / "app.svg"
|
||||
if app_svg.is_file():
|
||||
box.setIconPixmap(QIcon(str(app_svg)).pixmap(64, 64))
|
||||
|
|
|
|||
|
|
@ -34,14 +34,21 @@ from otko.services.element_forces import DiagramData, ForceComponent
|
|||
_LOG = logging.getLogger("otko.diagram")
|
||||
|
||||
# Components that draw perpendicular to the element axis vs along it.
|
||||
_PERPENDICULAR = {ForceComponent.V2, ForceComponent.V3,
|
||||
ForceComponent.M2, ForceComponent.M3, ForceComponent.T}
|
||||
_PERPENDICULAR = {
|
||||
ForceComponent.V2,
|
||||
ForceComponent.V3,
|
||||
ForceComponent.M2,
|
||||
ForceComponent.M3,
|
||||
ForceComponent.T,
|
||||
}
|
||||
|
||||
# Which local axis the value is plotted along (2 = local y, 3 = local z).
|
||||
_LOCAL_AXIS = {
|
||||
ForceComponent.V2: 2, ForceComponent.M3: 2,
|
||||
ForceComponent.V3: 3, ForceComponent.M2: 3,
|
||||
ForceComponent.T: 2,
|
||||
ForceComponent.V2: 2,
|
||||
ForceComponent.M3: 2,
|
||||
ForceComponent.V3: 3,
|
||||
ForceComponent.M2: 3,
|
||||
ForceComponent.T: 2,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -69,8 +76,10 @@ class DiagramRenderer:
|
|||
# All values are zero → no diagram to draw. This is normal —
|
||||
# e.g. asking for "torsion" on a planar bending model. Log
|
||||
# a hint so the user understands the empty viewport.
|
||||
_LOG.info("All '%s' values are zero for this analysis step "
|
||||
"— nothing to draw.", data.component.name)
|
||||
_LOG.info(
|
||||
"All '%s' values are zero for this analysis step " "— nothing to draw.",
|
||||
data.component.name,
|
||||
)
|
||||
return
|
||||
|
||||
node_pos = {n.id: np.asarray(n.coords, dtype=float) for n in project.nodes}
|
||||
|
|
@ -79,8 +88,8 @@ class DiagramRenderer:
|
|||
is_perpendicular = data.component in _PERPENDICULAR
|
||||
axis_id = _LOCAL_AXIS.get(data.component, 2)
|
||||
|
||||
polys: list[np.ndarray] = [] # vertex arrays for each polygon
|
||||
scalars: list[float] = [] # one value per polygon (avg of end values)
|
||||
polys: list[np.ndarray] = [] # vertex arrays for each polygon
|
||||
scalars: list[float] = [] # one value per polygon (avg of end values)
|
||||
for k, eid in enumerate(data.element_ids):
|
||||
elem = elem_lookup.get(int(eid))
|
||||
if elem is None:
|
||||
|
|
@ -106,15 +115,19 @@ class DiagramRenderer:
|
|||
perp = self._local_perp(pi, pj, 2)
|
||||
if perp is None:
|
||||
continue
|
||||
width = abs(v_i) * 0.5 # half-width fall-off
|
||||
width = abs(v_i) * 0.5 # half-width fall-off
|
||||
if width == 0.0:
|
||||
width = abs(v_j) * 0.5
|
||||
if width == 0.0:
|
||||
continue
|
||||
quad = np.vstack([
|
||||
pi - perp * width, pj - perp * width,
|
||||
pj + perp * width, pi + perp * width,
|
||||
])
|
||||
quad = np.vstack(
|
||||
[
|
||||
pi - perp * width,
|
||||
pj - perp * width,
|
||||
pj + perp * width,
|
||||
pi + perp * width,
|
||||
]
|
||||
)
|
||||
polys.append(quad)
|
||||
scalars.append(0.5 * (data.values_i[k] + data.values_j[k]))
|
||||
|
||||
|
|
@ -138,7 +151,9 @@ class DiagramRenderer:
|
|||
# Symmetric color range so zero stays at the colormap mid-point.
|
||||
vmax = float(np.max(np.abs(scalars))) or 1.0
|
||||
# Defensive: accept either ForceComponent enum or its name string.
|
||||
comp_label = data.component.value if hasattr(data.component, "value") else str(data.component)
|
||||
comp_label = (
|
||||
data.component.value if hasattr(data.component, "value") else str(data.component)
|
||||
)
|
||||
self._actor = self._plotter.add_mesh(
|
||||
mesh,
|
||||
scalars="value",
|
||||
|
|
@ -157,8 +172,7 @@ class DiagramRenderer:
|
|||
# ── Numerical labels at the global min and max element ends. ──
|
||||
self._label_actor = self._add_value_labels(project, data, scale)
|
||||
|
||||
def _add_value_labels(self, project: Project, data: DiagramData,
|
||||
scale: float) -> Any:
|
||||
def _add_value_labels(self, project: Project, data: DiagramData, scale: float) -> Any:
|
||||
"""Annotate the diagram's extreme ends with their numerical values.
|
||||
|
||||
Avoids visual clutter by labelling only the two ends carrying the
|
||||
|
|
@ -203,9 +217,10 @@ class DiagramRenderer:
|
|||
|
||||
try:
|
||||
return self._plotter.add_point_labels(
|
||||
positions, labels,
|
||||
positions,
|
||||
labels,
|
||||
font_size=14,
|
||||
point_size=0, # don't draw the underlying points
|
||||
point_size=0, # don't draw the underlying points
|
||||
shape=None,
|
||||
always_visible=True,
|
||||
pickable=False,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ needing a reference to the renderer.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
|
@ -26,7 +25,7 @@ from otko.views.canvas3d.model_renderer import ModelRenderer
|
|||
from otko.views.canvas3d.selection import SelectionState
|
||||
from otko.views.canvas3d.style import RenderStyle
|
||||
|
||||
_PICK_DEBUG = os.environ.get("OSS_PICK_DEBUG") == "1"
|
||||
PICK_DEBUG = False
|
||||
|
||||
|
||||
class ModelCanvas(QtInteractor): # type: ignore[misc]
|
||||
|
|
@ -127,7 +126,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
cx = qt_x * dpr
|
||||
cy = (h_logical - qt_y) * dpr
|
||||
|
||||
if _PICK_DEBUG:
|
||||
if PICK_DEBUG:
|
||||
print(
|
||||
f"[pick] click qt=({qt_x:.0f},{qt_y:.0f}) → vtk=({cx:.0f},{cy:.0f}) "
|
||||
f"viewport_logical=({self.width()}x{h_logical}) dpr={dpr}"
|
||||
|
|
@ -148,7 +147,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
if node_screen is not None and len(node_screen):
|
||||
d2 = (node_screen[:, 0] - cx) ** 2 + (node_screen[:, 1] - cy) ** 2
|
||||
idx = int(np.argmin(d2))
|
||||
if _PICK_DEBUG:
|
||||
if PICK_DEBUG:
|
||||
print(
|
||||
f"[pick] nearest node id={node_ids[idx]} "
|
||||
f"screen={node_screen[idx]} d={np.sqrt(d2[idx]):.1f}px "
|
||||
|
|
@ -181,7 +180,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
closest = a_screen + t[:, None] * ab
|
||||
d2 = ((p - closest) ** 2).sum(axis=1)
|
||||
idx = int(np.argmin(d2))
|
||||
if _PICK_DEBUG:
|
||||
if PICK_DEBUG:
|
||||
print(
|
||||
f"[pick] nearest frame id={frame_ids[idx]} "
|
||||
f"d={float(np.sqrt(d2[idx])):.1f}px "
|
||||
|
|
@ -191,7 +190,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
self._dispatch_pick("element", int(frame_ids[idx]))
|
||||
return
|
||||
|
||||
if _PICK_DEBUG:
|
||||
if PICK_DEBUG:
|
||||
print("[pick] no hit within tolerance")
|
||||
|
||||
# ── Empty-click fallback: pixel-space snap to grid intersections. ──
|
||||
|
|
@ -202,7 +201,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
snapped = self._nearest_grid_intersection_px(cx, cy, grid_tol_px)
|
||||
if snapped is not None:
|
||||
self.emptyClicked.emit(float(snapped[0]), float(snapped[1]), float(snapped[2]))
|
||||
elif _PICK_DEBUG:
|
||||
elif PICK_DEBUG:
|
||||
print("[pick] off-grid click — no snap target within tolerance")
|
||||
|
||||
def _grid_intersections_world(self) -> np.ndarray | None:
|
||||
|
|
@ -260,7 +259,7 @@ class ModelCanvas(QtInteractor): # type: ignore[misc]
|
|||
return None
|
||||
d2 = (screen[:, 0] - cx) ** 2 + (screen[:, 1] - cy) ** 2
|
||||
idx = int(np.argmin(d2))
|
||||
if _PICK_DEBUG:
|
||||
if PICK_DEBUG:
|
||||
print(
|
||||
f"[grid-snap] nearest intersection "
|
||||
f"world={world_points[idx]} d={float(np.sqrt(d2[idx])):.1f}px "
|
||||
|
|
|
|||
|
|
@ -19,15 +19,15 @@ class RenderStyle:
|
|||
background_top: str = "#dbe2ef"
|
||||
background_bottom: str = "#f5f7fb"
|
||||
|
||||
node_color: str = "#f0a500" # warm gold
|
||||
node_selected_color: str = "#00d4ff" # bright cyan
|
||||
node_color: str = "#f0a500" # warm gold
|
||||
node_selected_color: str = "#00d4ff" # bright cyan
|
||||
|
||||
frame_color: str = "#1f1f1f"
|
||||
truss_color: str = "#2e5cb8"
|
||||
zerolength_color: str = "#a020f0"
|
||||
selected_color: str = "#00d4ff"
|
||||
|
||||
fix_color: str = "#c0392b" # firebrick
|
||||
fix_color: str = "#c0392b" # firebrick
|
||||
pin_color: str = "#c0392b"
|
||||
roller_color: str = "#e67e22"
|
||||
custom_support_color: str = "#7f8c8d"
|
||||
|
|
@ -45,7 +45,7 @@ class RenderStyle:
|
|||
support_relative_size: float = 0.025
|
||||
support_min_size: float = 0.10
|
||||
|
||||
load_relative_length: float = 0.10 # arrow length / bbox_diag
|
||||
load_relative_length: float = 0.10 # arrow length / bbox_diag
|
||||
load_min_length: float = 0.5
|
||||
|
||||
selection_thickness_factor: float = 1.6 # multiplier for selected actors
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from otko.views.dialogs.assign_property import (
|
|||
from otko.views.dialogs.assign_support import PRESETS, AssignSupportDialog
|
||||
from otko.views.dialogs.assign_zls import AssignZeroLengthSectionDialog
|
||||
from otko.views.dialogs.case_manager import AnalysisCaseManagerDialog
|
||||
from otko.views.dialogs.combination_dialog import LoadCombinationManagerDialog
|
||||
from otko.views.dialogs.coord_grid_systems import (
|
||||
CoordinateGridSystemsDialog,
|
||||
CoordSystemDataDialog,
|
||||
|
|
@ -60,6 +61,7 @@ __all__ = [
|
|||
"DisplayOptionsDialog",
|
||||
"GridSystemDialog",
|
||||
"LinearTimeSeriesDialog",
|
||||
"LoadCombinationManagerDialog",
|
||||
"MaterialLibraryDialog",
|
||||
"MaterialTesterDialog",
|
||||
"MirrorDialog",
|
||||
|
|
|
|||
|
|
@ -40,12 +40,15 @@ class AddNodeDialog(QDialog):
|
|||
the node lands on the active plane by default.
|
||||
"""
|
||||
|
||||
def __init__(self, next_node_id: int,
|
||||
grid: GridSystem,
|
||||
ndm: int = 3,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
plane_elevation: float | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
next_node_id: int,
|
||||
grid: GridSystem,
|
||||
ndm: int = 3,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
plane_elevation: float | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Add Node")
|
||||
self._next_id = next_node_id
|
||||
|
|
@ -71,16 +74,18 @@ class AddNodeDialog(QDialog):
|
|||
else:
|
||||
self._z.setVisible(False)
|
||||
if self._plane_elevation is not None and self._ndm == 3:
|
||||
form.addRow(QLabel(
|
||||
f"<i>Suggested Z = active working-plane level "
|
||||
f"({self._plane_elevation:g}).</i>",
|
||||
))
|
||||
form.addRow(
|
||||
QLabel(
|
||||
f"<i>Suggested Z = active working-plane level "
|
||||
f"({self._plane_elevation:g}).</i>",
|
||||
)
|
||||
)
|
||||
root.addLayout(form)
|
||||
|
||||
self._snap_cb = QCheckBox("Snap to nearest grid intersection")
|
||||
self._snap_cb.setChecked(bool(
|
||||
self._grid.x_lines or self._grid.y_lines or self._grid.z_lines
|
||||
))
|
||||
self._snap_cb.setChecked(
|
||||
bool(self._grid.x_lines or self._grid.y_lines or self._grid.z_lines)
|
||||
)
|
||||
root.addWidget(self._snap_cb)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
|
|
|
|||
|
|
@ -42,10 +42,12 @@ class AssignEqualDOFDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
"Tie selected nodes together in chosen DOFs using OpenSees "
|
||||
"<code>equalDOF</code>.",
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
"Tie selected nodes together in chosen DOFs using OpenSees "
|
||||
"<code>equalDOF</code>.",
|
||||
)
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
|
|
@ -64,7 +66,7 @@ class AssignEqualDOFDialog(QDialog):
|
|||
dof_form = QFormLayout()
|
||||
self._dof_boxes: list[QCheckBox] = []
|
||||
labels = ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz")
|
||||
active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2:self._ndf])
|
||||
active_labels = labels[:2] + (labels[5:6] if self._ndf == 3 else labels[2 : self._ndf])
|
||||
if self._ndf == 2:
|
||||
active_labels = labels[:2]
|
||||
elif self._ndf == 3:
|
||||
|
|
@ -79,7 +81,10 @@ class AssignEqualDOFDialog(QDialog):
|
|||
if self._suggested_dofs is not None and dof_id in self._suggested_dofs:
|
||||
cb.setChecked(True)
|
||||
self._dof_boxes.append(cb)
|
||||
dof_form.addRow(f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:", cb)
|
||||
dof_form.addRow(
|
||||
f"DOF {i if self._ndf != 3 else (1 if label=='Ux' else 2 if label=='Uy' else 3)}:",
|
||||
cb,
|
||||
)
|
||||
layout.addLayout(dof_form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
|
|
|
|||
|
|
@ -64,9 +64,7 @@ class AssignHingeDialog(QDialog):
|
|||
|
||||
def _build_ui(self, n_selected: int) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
header = (
|
||||
f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
|
||||
)
|
||||
header = f"Convert <b>{n_selected}</b> selected element(s) to BeamWithHinges."
|
||||
prefill = self._selection_prefill()
|
||||
if prefill is None and self._element_ids:
|
||||
header += " <i>Selection is mixed — unit-scaled defaults shown.</i>"
|
||||
|
|
@ -160,18 +158,20 @@ class AssignHingeDialog(QDialog):
|
|||
if el is None:
|
||||
return None
|
||||
if getattr(el, "type", None) == "BeamWithHinges":
|
||||
collected.append({
|
||||
"section_i_id": getattr(el, "section_i_id", None),
|
||||
"section_j_id": getattr(el, "section_j_id", None),
|
||||
"lp_i": getattr(el, "lp_i", None),
|
||||
"lp_j": getattr(el, "lp_j", None),
|
||||
"E": getattr(el, "E", None),
|
||||
"A": getattr(el, "A", None),
|
||||
"Iz": getattr(el, "Iz", None),
|
||||
"Iy": getattr(el, "Iy", None),
|
||||
"G": getattr(el, "G", None),
|
||||
"J": getattr(el, "J", None),
|
||||
})
|
||||
collected.append(
|
||||
{
|
||||
"section_i_id": getattr(el, "section_i_id", None),
|
||||
"section_j_id": getattr(el, "section_j_id", None),
|
||||
"lp_i": getattr(el, "lp_i", None),
|
||||
"lp_j": getattr(el, "lp_j", None),
|
||||
"E": getattr(el, "E", None),
|
||||
"A": getattr(el, "A", None),
|
||||
"Iz": getattr(el, "Iz", None),
|
||||
"Iy": getattr(el, "Iy", None),
|
||||
"G": getattr(el, "G", None),
|
||||
"J": getattr(el, "J", None),
|
||||
}
|
||||
)
|
||||
continue
|
||||
sec_id = getattr(el, "section_id", None)
|
||||
if sec_id is None:
|
||||
|
|
@ -185,21 +185,22 @@ class AssignHingeDialog(QDialog):
|
|||
# prefill the hinge pickers with the section itself.
|
||||
collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
|
||||
continue
|
||||
collected.append({
|
||||
"section_i_id": sec_id,
|
||||
"section_j_id": sec_id,
|
||||
"E": getattr(sec, "E", None),
|
||||
"A": getattr(sec, "A", None),
|
||||
"Iz": getattr(sec, "Iz", None),
|
||||
"Iy": getattr(sec, "Iy", None),
|
||||
"G": getattr(sec, "G", None),
|
||||
"J": getattr(sec, "J", None),
|
||||
})
|
||||
collected.append(
|
||||
{
|
||||
"section_i_id": sec_id,
|
||||
"section_j_id": sec_id,
|
||||
"E": getattr(sec, "E", None),
|
||||
"A": getattr(sec, "A", None),
|
||||
"Iz": getattr(sec, "Iz", None),
|
||||
"Iy": getattr(sec, "Iy", None),
|
||||
"G": getattr(sec, "G", None),
|
||||
"J": getattr(sec, "J", None),
|
||||
}
|
||||
)
|
||||
if not collected:
|
||||
return None
|
||||
merged: dict = {}
|
||||
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j",
|
||||
"E", "A", "Iz", "Iy", "G", "J"):
|
||||
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", "E", "A", "Iz", "Iy", "G", "J"):
|
||||
vals = {c.get(key) for c in collected if c.get(key) is not None}
|
||||
if len(vals) == 1:
|
||||
merged[key] = next(iter(vals))
|
||||
|
|
|
|||
|
|
@ -80,9 +80,7 @@ class AssignLoadDialog(QDialog):
|
|||
pf.addRow("Active:", self._active_pattern_label)
|
||||
|
||||
self._new_name_edit = QLineEdit("Pattern")
|
||||
self._new_name_edit.setPlaceholderText(
|
||||
"Name for the new pattern (e.g. RefMoment)"
|
||||
)
|
||||
self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)")
|
||||
pf.addRow("New name:", self._new_name_edit)
|
||||
|
||||
# TimeSeries type selector — only used when creating a new
|
||||
|
|
|
|||
|
|
@ -56,16 +56,22 @@ class AssignMassesDialog(QDialog):
|
|||
|
||||
cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
form = QFormLayout()
|
||||
self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx)
|
||||
self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my)
|
||||
self._mx = self._spin(cur[0])
|
||||
form.addRow("Translation X:", self._mx)
|
||||
self._my = self._spin(cur[1])
|
||||
form.addRow("Translation Y:", self._my)
|
||||
if self._ndf >= 3:
|
||||
self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz)
|
||||
self._mz = self._spin(cur[2])
|
||||
form.addRow("Translation Z:", self._mz)
|
||||
else:
|
||||
self._mz = self._spin(cur[2])
|
||||
if self._ndf == 6:
|
||||
self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx)
|
||||
self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy)
|
||||
self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz)
|
||||
self._mxx = self._spin(cur[3])
|
||||
form.addRow("Rotation X (Ixx):", self._mxx)
|
||||
self._myy = self._spin(cur[4])
|
||||
form.addRow("Rotation Y (Iyy):", self._myy)
|
||||
self._mzz = self._spin(cur[5])
|
||||
form.addRow("Rotation Z (Izz):", self._mzz)
|
||||
else:
|
||||
self._mxx = self._spin(cur[3])
|
||||
self._myy = self._spin(cur[4])
|
||||
|
|
@ -77,8 +83,7 @@ class AssignMassesDialog(QDialog):
|
|||
root.addWidget(self._xy_link)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
@ -106,6 +111,10 @@ class AssignMassesDialog(QDialog):
|
|||
def mass_vector(self) -> tuple[float, float, float, float, float, float]:
|
||||
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
|
||||
return (
|
||||
self._mx.value(), self._my.value(), self._mz.value(),
|
||||
self._mxx.value(), self._myy.value(), self._mzz.value(),
|
||||
self._mx.value(),
|
||||
self._my.value(),
|
||||
self._mz.value(),
|
||||
self._mxx.value(),
|
||||
self._myy.value(),
|
||||
self._mzz.value(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -41,9 +41,9 @@ class AssignSectionDialog(QDialog):
|
|||
layout.addWidget(QLabel(self._current_note()))
|
||||
|
||||
if not self._sections:
|
||||
layout.addWidget(QLabel(
|
||||
"<i>No sections defined. Open Define → Section Library first.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel("<i>No sections defined. Open Define → Section Library first.</i>")
|
||||
)
|
||||
|
||||
self._combo = QComboBox()
|
||||
for s in self._sections:
|
||||
|
|
@ -115,15 +115,15 @@ class AssignMaterialDialog(QDialog):
|
|||
|
||||
def _build_ui(self, n_elements: int) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s)."
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel(f"Apply material to <b>{n_elements}</b> selected truss/zero-length element(s).")
|
||||
)
|
||||
layout.addWidget(QLabel(self._current_note()))
|
||||
|
||||
if not self._materials:
|
||||
layout.addWidget(QLabel(
|
||||
"<i>No materials defined. Open Define → Material Library first.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel("<i>No materials defined. Open Define → Material Library first.</i>")
|
||||
)
|
||||
|
||||
self._combo = QComboBox()
|
||||
for m in self._materials:
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@ from PySide6.QtWidgets import (
|
|||
|
||||
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
|
||||
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
|
||||
"Free": (False, False, False, False, False, False),
|
||||
"Roller (Z)": (False, False, True, False, False, False),
|
||||
"Pin": (True, True, True, False, False, False),
|
||||
"Fix": (True, True, True, True, True, True),
|
||||
"Free": (False, False, False, False, False, False),
|
||||
"Roller (Z)": (False, False, True, False, False, False),
|
||||
"Pin": (True, True, True, False, False, False),
|
||||
"Fix": (True, True, True, True, True, True),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,8 +25,9 @@ from otko.core import Project
|
|||
class AssignZeroLengthSectionDialog(QDialog):
|
||||
"""Modal dialog: pick a section, confirm creation between 2 nodes."""
|
||||
|
||||
def __init__(self, project: Project, node_ids: tuple[int, int],
|
||||
parent: QWidget | None = None) -> None:
|
||||
def __init__(
|
||||
self, project: Project, node_ids: tuple[int, int], parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Assign Zero-Length Section")
|
||||
self._project = project
|
||||
|
|
@ -35,12 +36,14 @@ class AssignZeroLengthSectionDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel(
|
||||
f"Connect node <b>{self._node_ids[0]}</b> and "
|
||||
f"<b>{self._node_ids[1]}</b> through a zero-length "
|
||||
"section element. The two nodes must share the same "
|
||||
"coordinates."
|
||||
))
|
||||
root.addWidget(
|
||||
QLabel(
|
||||
f"Connect node <b>{self._node_ids[0]}</b> and "
|
||||
f"<b>{self._node_ids[1]}</b> through a zero-length "
|
||||
"section element. The two nodes must share the same "
|
||||
"coordinates."
|
||||
)
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
self._section_cb = QComboBox()
|
||||
|
|
@ -53,8 +56,7 @@ class AssignZeroLengthSectionDialog(QDialog):
|
|||
root.addLayout(form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
|
|||
|
|
@ -37,9 +37,14 @@ from otko.core import (
|
|||
|
||||
|
||||
# ─────────────────────────── helpers ───────────────────────────
|
||||
def _spin(default: float = 0.0, *, decimals: int = 6,
|
||||
minimum: float = -1e15, maximum: float = 1e15,
|
||||
step: float = 1.0) -> QDoubleSpinBox:
|
||||
def _spin(
|
||||
default: float = 0.0,
|
||||
*,
|
||||
decimals: int = 6,
|
||||
minimum: float = -1e15,
|
||||
maximum: float = 1e15,
|
||||
step: float = 1.0,
|
||||
) -> QDoubleSpinBox:
|
||||
sb = QDoubleSpinBox()
|
||||
sb.setRange(minimum, maximum)
|
||||
sb.setDecimals(decimals)
|
||||
|
|
@ -227,7 +232,14 @@ class CaseFormBase(QWidget):
|
|||
|
||||
|
||||
# ─────────────────────────── Static ───────────────────────────
|
||||
_STATIC_SYSTEMS = ["BandGeneral", "BandSPD", "ProfileSPD", "SparseGeneral", "UmfPack", "FullGeneral"]
|
||||
_STATIC_SYSTEMS = [
|
||||
"BandGeneral",
|
||||
"BandSPD",
|
||||
"ProfileSPD",
|
||||
"SparseGeneral",
|
||||
"UmfPack",
|
||||
"FullGeneral",
|
||||
]
|
||||
_CONSTRAINTS = ["Plain", "Lagrange", "Penalty", "Transformation"]
|
||||
_INTEGRATORS_STATIC = ["LoadControl", "DisplacementControl", "ArcLength"]
|
||||
_ALGORITHMS = ["Linear", "Newton", "ModifiedNewton", "KrylovNewton", "BFGS", "Broyden"]
|
||||
|
|
@ -291,7 +303,8 @@ class StaticCaseForm(CaseFormBase):
|
|||
|
||||
def _read_specific(self, cid: int) -> StaticCase:
|
||||
return StaticCase(
|
||||
id=cid, name=self._name_edit.text(),
|
||||
id=cid,
|
||||
name=self._name_edit.text(),
|
||||
pattern_ids=_require_pattern_ids(self._patterns_picker),
|
||||
**_factor_kwargs(StaticCase, self._patterns_picker, self._pattern_factor_spins),
|
||||
n_steps=self._n_steps.value(),
|
||||
|
|
@ -322,9 +335,9 @@ class ModalCaseForm(CaseFormBase):
|
|||
self._solver.addItems(["genBandArpack", "fullGenLapack", "symmBandLapack"])
|
||||
self._layout.addRow("Number of modes:", self._n_modes)
|
||||
self._layout.addRow("Solver:", self._solver)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>The runner auto-falls back to fullGenLapack for very small models.</i>"
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel("<i>The runner auto-falls back to fullGenLapack for very small models.</i>")
|
||||
)
|
||||
|
||||
def _populate_specific(self, c: ModalCase) -> None:
|
||||
self._n_modes.setValue(c.n_modes)
|
||||
|
|
@ -332,7 +345,8 @@ class ModalCaseForm(CaseFormBase):
|
|||
|
||||
def _read_specific(self, cid: int) -> ModalCase:
|
||||
return ModalCase(
|
||||
id=cid, name=self._name_edit.text(),
|
||||
id=cid,
|
||||
name=self._name_edit.text(),
|
||||
n_modes=self._n_modes.value(),
|
||||
solver=self._solver.currentText(),
|
||||
)
|
||||
|
|
@ -359,13 +373,19 @@ class TransientCaseForm(CaseFormBase):
|
|||
self._remove_patterns_picker = _make_pattern_picker(patterns)
|
||||
self._dt = _spin(0.01, decimals=8, minimum=1e-12, step=1e-3)
|
||||
self._n_steps = _int_spin(1000, minimum=1, maximum=10_000_000)
|
||||
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
|
||||
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
|
||||
self._integrator = QComboBox(); self._integrator.addItems(_INTEGRATORS_TRANSIENT)
|
||||
self._system = QComboBox()
|
||||
self._system.addItems(_STATIC_SYSTEMS)
|
||||
self._constraints = QComboBox()
|
||||
self._constraints.addItems(_CONSTRAINTS)
|
||||
self._integrator = QComboBox()
|
||||
self._integrator.addItems(_INTEGRATORS_TRANSIENT)
|
||||
self._gamma = _spin(0.5, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
|
||||
self._beta = _spin(0.25, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
|
||||
self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS); self._algorithm.setCurrentText("Newton")
|
||||
self._test = QComboBox(); self._test.addItems(_TESTS)
|
||||
self._algorithm = QComboBox()
|
||||
self._algorithm.addItems(_ALGORITHMS)
|
||||
self._algorithm.setCurrentText("Newton")
|
||||
self._test = QComboBox()
|
||||
self._test.addItems(_TESTS)
|
||||
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
|
||||
self._max_iter = _int_spin(25)
|
||||
self._alpha_m = _spin(0.0, decimals=8, minimum=0.0, maximum=1e12, step=1e-4)
|
||||
|
|
@ -376,10 +396,12 @@ class TransientCaseForm(CaseFormBase):
|
|||
self._layout.addRow(self._patterns_picker)
|
||||
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
|
||||
self._layout.addRow(self._preload_picker)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>Run these Static cases first, then hold them constant via "
|
||||
"loadConst -time 0.0 before the transient starts.</i>"
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel(
|
||||
"<i>Run these Static cases first, then hold them constant via "
|
||||
"loadConst -time 0.0 before the transient starts.</i>"
|
||||
)
|
||||
)
|
||||
self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>"))
|
||||
self._layout.addRow(self._remove_patterns_picker)
|
||||
self._layout.addRow("dt:", self._dt)
|
||||
|
|
@ -396,17 +418,20 @@ class TransientCaseForm(CaseFormBase):
|
|||
self._layout.addRow("Rayleigh αM:", self._alpha_m)
|
||||
self._layout.addRow("Rayleigh βK:", self._beta_k)
|
||||
self._layout.addRow("Mode-1 damping ratio:", self._mode1_damping)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
|
||||
"after preload and uses it instead of the manual βK value.</i>"
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel(
|
||||
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
|
||||
"after preload and uses it instead of the manual βK value.</i>"
|
||||
)
|
||||
)
|
||||
|
||||
def _populate_specific(self, c: TransientCase) -> None:
|
||||
_select_pattern_ids(self._patterns_picker, c.pattern_ids)
|
||||
_set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {})
|
||||
_select_case_ids(self._preload_picker, c.preload_case_ids)
|
||||
_select_pattern_ids(self._remove_patterns_picker, c.remove_patterns)
|
||||
self._dt.setValue(c.dt); self._n_steps.setValue(c.n_steps)
|
||||
self._dt.setValue(c.dt)
|
||||
self._n_steps.setValue(c.n_steps)
|
||||
self._system.setCurrentText(c.system)
|
||||
self._constraints.setCurrentText(c.constraints)
|
||||
self._integrator.setCurrentText(c.integrator)
|
||||
|
|
@ -423,12 +448,14 @@ class TransientCaseForm(CaseFormBase):
|
|||
def _read_specific(self, cid: int) -> TransientCase:
|
||||
mode1_damping = self._mode1_damping.value()
|
||||
return TransientCase(
|
||||
id=cid, name=self._name_edit.text(),
|
||||
id=cid,
|
||||
name=self._name_edit.text(),
|
||||
pattern_ids=_require_pattern_ids(self._patterns_picker),
|
||||
**_factor_kwargs(TransientCase, self._patterns_picker, self._pattern_factor_spins),
|
||||
preload_case_ids=_selected_case_ids(self._preload_picker),
|
||||
remove_patterns=_selected_pattern_ids(self._remove_patterns_picker),
|
||||
dt=self._dt.value(), n_steps=self._n_steps.value(),
|
||||
dt=self._dt.value(),
|
||||
n_steps=self._n_steps.value(),
|
||||
system=self._system.currentText(),
|
||||
constraints=self._constraints.currentText(),
|
||||
integrator=self._integrator.currentText(),
|
||||
|
|
@ -462,12 +489,18 @@ class PushoverCaseForm(CaseFormBase):
|
|||
self._target = _spin(0.1, decimals=6, minimum=-1e6, maximum=1e6, step=0.001)
|
||||
self._step = _spin(0.001, decimals=8, minimum=1e-12, step=1e-4)
|
||||
self._base_nodes = QLineEdit()
|
||||
self._base_nodes.setPlaceholderText("comma-separated node ids (leave blank for all supports)")
|
||||
self._system = QComboBox(); self._system.addItems(_STATIC_SYSTEMS)
|
||||
self._constraints = QComboBox(); self._constraints.addItems(_CONSTRAINTS)
|
||||
self._algorithm = QComboBox(); self._algorithm.addItems(_ALGORITHMS)
|
||||
self._base_nodes.setPlaceholderText(
|
||||
"comma-separated node ids (leave blank for all supports)"
|
||||
)
|
||||
self._system = QComboBox()
|
||||
self._system.addItems(_STATIC_SYSTEMS)
|
||||
self._constraints = QComboBox()
|
||||
self._constraints.addItems(_CONSTRAINTS)
|
||||
self._algorithm = QComboBox()
|
||||
self._algorithm.addItems(_ALGORITHMS)
|
||||
self._algorithm.setCurrentText("Newton")
|
||||
self._test = QComboBox(); self._test.addItems(_TESTS)
|
||||
self._test = QComboBox()
|
||||
self._test.addItems(_TESTS)
|
||||
self._tol = _spin(1e-6, decimals=12, minimum=1e-15, step=1e-7)
|
||||
self._max_iter = _int_spin(25)
|
||||
|
||||
|
|
@ -475,10 +508,12 @@ class PushoverCaseForm(CaseFormBase):
|
|||
self._layout.addRow(self._patterns_picker)
|
||||
self._layout.addRow(QLabel("<b>Preload static cases (optional):</b>"))
|
||||
self._layout.addRow(self._preload_picker)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>Run these Static cases first, then hold them constant via "
|
||||
"loadConst -time 0.0 before the pushover starts.</i>"
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel(
|
||||
"<i>Run these Static cases first, then hold them constant via "
|
||||
"loadConst -time 0.0 before the pushover starts.</i>"
|
||||
)
|
||||
)
|
||||
self._layout.addRow("Control node:", self._control_node)
|
||||
self._layout.addRow("Control DOF:", self._control_dof)
|
||||
self._layout.addRow("Target displacement:", self._target)
|
||||
|
|
@ -511,7 +546,8 @@ class PushoverCaseForm(CaseFormBase):
|
|||
txt = self._base_nodes.text().strip()
|
||||
base_ids = [int(x) for x in txt.replace(",", " ").split() if x] if txt else []
|
||||
return PushoverCase(
|
||||
id=cid, name=self._name_edit.text(),
|
||||
id=cid,
|
||||
name=self._name_edit.text(),
|
||||
pattern_ids=_require_pattern_ids(self._patterns_picker),
|
||||
**_factor_kwargs(PushoverCase, self._patterns_picker, self._pattern_factor_spins),
|
||||
preload_case_ids=_selected_case_ids(self._preload_picker),
|
||||
|
|
@ -552,10 +588,12 @@ class ResponseSpectrumCaseForm(CaseFormBase):
|
|||
self._layout.addRow("Direction (DOF):", self._direction)
|
||||
self._layout.addRow("Combination:", self._combination)
|
||||
self._layout.addRow("Damping (CQC override):", self._damping)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>Damping is used by CQC modal correlation only; "
|
||||
"leave at 0 to use the spectrum's own damping ratio.</i>",
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel(
|
||||
"<i>Damping is used by CQC modal correlation only; "
|
||||
"leave at 0 to use the spectrum's own damping ratio.</i>",
|
||||
)
|
||||
)
|
||||
|
||||
def _populate_specific(self, c: ResponseSpectrumCase) -> None:
|
||||
self._modal_case.setValue(c.modal_case_id)
|
||||
|
|
@ -568,7 +606,8 @@ class ResponseSpectrumCaseForm(CaseFormBase):
|
|||
def _read_specific(self, cid: int) -> ResponseSpectrumCase:
|
||||
damp_val = self._damping.value()
|
||||
return ResponseSpectrumCase(
|
||||
id=cid, name=self._name_edit.text(),
|
||||
id=cid,
|
||||
name=self._name_edit.text(),
|
||||
modal_case_id=self._modal_case.value(),
|
||||
spectrum_id=self._spectrum_id.value(),
|
||||
direction=self._direction.value(),
|
||||
|
|
|
|||
|
|
@ -27,7 +27,11 @@ from otko.commands import (
|
|||
UpdateAnalysisCaseCommand,
|
||||
)
|
||||
from otko.core import (
|
||||
ModalCase, PushoverCase, ResponseSpectrumCase, StaticCase, TransientCase,
|
||||
ModalCase,
|
||||
PushoverCase,
|
||||
ResponseSpectrumCase,
|
||||
StaticCase,
|
||||
TransientCase,
|
||||
)
|
||||
from otko.viewmodels import ProjectViewModel
|
||||
from otko.views.dialogs.case_forms import FORM_REGISTRY, form_for
|
||||
|
|
@ -56,16 +60,23 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
|
|||
return ModalCase(id=cid, name="Modal", n_modes=3)
|
||||
if kind == "Transient":
|
||||
return TransientCase(
|
||||
id=cid, name="Transient", pattern_ids=[_first_pattern_id(project)],
|
||||
dt=0.01, n_steps=1000,
|
||||
id=cid,
|
||||
name="Transient",
|
||||
pattern_ids=[_first_pattern_id(project)],
|
||||
dt=0.01,
|
||||
n_steps=1000,
|
||||
)
|
||||
if kind == "Pushover":
|
||||
if not project.nodes:
|
||||
raise ValueError("Define at least one node before adding a Pushover case.")
|
||||
return PushoverCase(
|
||||
id=cid, name="Pushover", pattern_ids=[_first_pattern_id(project)],
|
||||
control_node=project.nodes[0].id, control_dof=1,
|
||||
target_disp=0.1, step_size=0.001,
|
||||
id=cid,
|
||||
name="Pushover",
|
||||
pattern_ids=[_first_pattern_id(project)],
|
||||
control_node=project.nodes[0].id,
|
||||
control_dof=1,
|
||||
target_disp=0.1,
|
||||
step_size=0.001,
|
||||
)
|
||||
if kind == "ResponseSpectrum":
|
||||
modal_ids = [c.id for c in project.analyses if isinstance(c, ModalCase)]
|
||||
|
|
@ -73,13 +84,15 @@ def _default_case(kind: str, cid: int, project: Project): # type: ignore[no-unt
|
|||
raise ValueError("Add a Modal case before adding a ResponseSpectrum case.")
|
||||
if not project.spectra:
|
||||
raise ValueError(
|
||||
"Define at least one response spectrum before adding "
|
||||
"a ResponseSpectrum case."
|
||||
"Define at least one response spectrum before adding " "a ResponseSpectrum case."
|
||||
)
|
||||
return ResponseSpectrumCase(
|
||||
id=cid, name="ResponseSpectrum",
|
||||
modal_case_id=modal_ids[0], spectrum_id=project.spectra[0].id,
|
||||
direction=1, combination="SRSS",
|
||||
id=cid,
|
||||
name="ResponseSpectrum",
|
||||
modal_case_id=modal_ids[0],
|
||||
spectrum_id=project.spectra[0].id,
|
||||
direction=1,
|
||||
combination="SRSS",
|
||||
)
|
||||
raise ValueError(f"Unknown analysis type: {kind}.")
|
||||
|
||||
|
|
@ -150,7 +163,7 @@ class AnalysisCaseManagerDialog(QDialog):
|
|||
if self._list.currentItem() is not None:
|
||||
selected_id = self._list.currentItem().data(Qt.ItemDataRole.UserRole)
|
||||
self._list.clear()
|
||||
for c in (self._vm.project.analyses if self._vm.project else []):
|
||||
for c in self._vm.project.analyses if self._vm.project else []:
|
||||
label = f"#{c.id} {c.name or '(unnamed)'} [{c.type}]"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.ItemDataRole.UserRole, c.id)
|
||||
|
|
@ -201,22 +214,31 @@ class AnalysisCaseManagerDialog(QDialog):
|
|||
# patterns). Do NOT fall through to the general type picker —
|
||||
# Static/Transient/Pushover would get dangling pattern refs.
|
||||
QMessageBox.information(
|
||||
self, "No patterns",
|
||||
self,
|
||||
"No patterns",
|
||||
"No load patterns defined yet — only a Modal case can be "
|
||||
"added (Modal works without patterns). Define a pattern "
|
||||
"first for Static, Transient, or Pushover cases.",
|
||||
)
|
||||
kind, ok = QInputDialog.getItem(
|
||||
self, "Add analysis case", "Type:",
|
||||
["Modal"], current=0, editable=False,
|
||||
self,
|
||||
"Add analysis case",
|
||||
"Type:",
|
||||
["Modal"],
|
||||
current=0,
|
||||
editable=False,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
self._create_case_of_kind("Modal")
|
||||
return
|
||||
kind, ok = QInputDialog.getItem(
|
||||
self, "Add analysis case", "Type:",
|
||||
list(_DEFAULTS.keys()), current=0, editable=False,
|
||||
self,
|
||||
"Add analysis case",
|
||||
"Type:",
|
||||
list(_DEFAULTS.keys()),
|
||||
current=0,
|
||||
editable=False,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -61,8 +61,7 @@ class CoordSystemDataDialog(QDialog):
|
|||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(
|
||||
"Edit Coordinate/Grid System"
|
||||
if existing is not None else "Add Coordinate/Grid System"
|
||||
"Edit Coordinate/Grid System" if existing is not None else "Add Coordinate/Grid System"
|
||||
)
|
||||
self._is_global = is_global
|
||||
self._existing = existing
|
||||
|
|
@ -88,28 +87,33 @@ class CoordSystemDataDialog(QDialog):
|
|||
loc_box = QGroupBox("Location and Orientation (relative to Global)")
|
||||
loc_form = QFormLayout(loc_box)
|
||||
|
||||
self._ox = self._spin(); self._oy = self._spin(); self._oz = self._spin()
|
||||
self._ox = self._spin()
|
||||
self._oy = self._spin()
|
||||
self._oz = self._spin()
|
||||
origin_row = QHBoxLayout()
|
||||
for label, w in (("X", self._ox), ("Y", self._oy), ("Z", self._oz)):
|
||||
origin_row.addWidget(QLabel(f"{label}:"))
|
||||
origin_row.addWidget(w)
|
||||
origin_wrap = QWidget(); origin_wrap.setLayout(origin_row)
|
||||
origin_wrap = QWidget()
|
||||
origin_wrap.setLayout(origin_row)
|
||||
loc_form.addRow("Origin:", origin_wrap)
|
||||
|
||||
self._rx = self._rot_spin(); self._ry = self._rot_spin(); self._rz = self._rot_spin()
|
||||
self._rx = self._rot_spin()
|
||||
self._ry = self._rot_spin()
|
||||
self._rz = self._rot_spin()
|
||||
rot_row = QHBoxLayout()
|
||||
for label, w in (("about X", self._rx), ("about Y", self._ry), ("about Z", self._rz)):
|
||||
rot_row.addWidget(QLabel(f"{label}:"))
|
||||
rot_row.addWidget(w)
|
||||
rot_wrap = QWidget(); rot_wrap.setLayout(rot_row)
|
||||
rot_wrap = QWidget()
|
||||
rot_wrap.setLayout(rot_row)
|
||||
loc_form.addRow("Rotation (deg):", rot_wrap)
|
||||
|
||||
if self._is_global:
|
||||
for w in (self._ox, self._oy, self._oz, self._rx, self._ry, self._rz):
|
||||
w.setEnabled(False)
|
||||
loc_box.setToolTip(
|
||||
"Global system is anchored at the world origin "
|
||||
"with identity orientation.",
|
||||
"Global system is anchored at the world origin " "with identity orientation.",
|
||||
)
|
||||
|
||||
layout.addWidget(loc_box)
|
||||
|
|
@ -160,8 +164,7 @@ class CoordSystemDataDialog(QDialog):
|
|||
|
||||
# ── Buttons ──────────────────────────────────────────────
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
@ -189,9 +192,13 @@ class CoordSystemDataDialog(QDialog):
|
|||
def _load_existing(self, cs: CoordinateGridSystem) -> None:
|
||||
self._name_edit.setText(cs.name)
|
||||
ox, oy, oz = cs.coord.origin
|
||||
self._ox.setValue(ox); self._oy.setValue(oy); self._oz.setValue(oz)
|
||||
self._ox.setValue(ox)
|
||||
self._oy.setValue(oy)
|
||||
self._oz.setValue(oz)
|
||||
rx, ry, rz = cs.coord.rotation_deg
|
||||
self._rx.setValue(rx); self._ry.setValue(ry); self._rz.setValue(rz)
|
||||
self._rx.setValue(rx)
|
||||
self._ry.setValue(ry)
|
||||
self._rz.setValue(rz)
|
||||
self._x_edit.setText(_spacings_text(cs.grid.x_lines))
|
||||
self._y_edit.setText(_spacings_text(cs.grid.y_lines))
|
||||
self._z_edit.setText(_spacings_text(cs.grid.z_lines))
|
||||
|
|
@ -245,9 +252,7 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
super().__init__(parent)
|
||||
self.setWindowTitle("Coordinate/Grid Systems")
|
||||
self.resize(600, 420)
|
||||
self._systems: list[CoordinateGridSystem] = [
|
||||
s.model_copy(deep=True) for s in systems
|
||||
]
|
||||
self._systems: list[CoordinateGridSystem] = [s.model_copy(deep=True) for s in systems]
|
||||
self._build_ui()
|
||||
self._refresh_list()
|
||||
|
||||
|
|
@ -290,8 +295,7 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
right.addStretch(1)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
@ -332,9 +336,7 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
self._btn_modify.setEnabled(cs is not None)
|
||||
self._cb_general.blockSignals(True)
|
||||
self._cb_general.setChecked(cs.grid.is_general if cs is not None else False)
|
||||
self._cb_general.setEnabled(
|
||||
cs is not None and not cs.grid.is_general
|
||||
)
|
||||
self._cb_general.setEnabled(cs is not None and not cs.grid.is_general)
|
||||
self._cb_general.blockSignals(False)
|
||||
|
||||
# ── actions ──────────────────────────────────────────────────
|
||||
|
|
@ -351,14 +353,17 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog(parent=self)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_sys = dlg.system()
|
||||
if any(s.name == new_sys.name for s in self._systems):
|
||||
new_sys = new_sys.model_copy(update={
|
||||
"name": self._unique_name(new_sys.name),
|
||||
})
|
||||
new_sys = new_sys.model_copy(
|
||||
update={
|
||||
"name": self._unique_name(new_sys.name),
|
||||
}
|
||||
)
|
||||
self._systems.append(new_sys)
|
||||
self._refresh_list(select_name=new_sys.name)
|
||||
|
||||
|
|
@ -380,8 +385,11 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog(
|
||||
existing=cs, is_global=cs.is_global(), parent=self,
|
||||
existing=cs,
|
||||
is_global=cs.is_global(),
|
||||
parent=self,
|
||||
)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
|
@ -392,9 +400,11 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
# Enforce unique names against siblings.
|
||||
others = [s for s in self._systems if s is not cs]
|
||||
if any(s.name == updated.name for s in others):
|
||||
updated = updated.model_copy(update={
|
||||
"name": self._unique_name(updated.name),
|
||||
})
|
||||
updated = updated.model_copy(
|
||||
update={
|
||||
"name": self._unique_name(updated.name),
|
||||
}
|
||||
)
|
||||
row = self._list.currentRow()
|
||||
self._systems[row] = updated
|
||||
self._refresh_list(select_name=updated.name)
|
||||
|
|
@ -414,7 +424,8 @@ class CoordinateGridSystemsDialog(QDialog):
|
|||
if cs.grid.is_general and not checked:
|
||||
# SAP2000: once converted to General, can't convert back.
|
||||
QMessageBox.information(
|
||||
self, "Convert to General",
|
||||
self,
|
||||
"Convert to General",
|
||||
"Once a system is converted to General, it cannot be "
|
||||
"converted back to a regular Cartesian system.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -127,9 +127,9 @@ class _AxisGridTable(QWidget):
|
|||
prev = 0.0
|
||||
for i, v in enumerate(ords):
|
||||
if i == 0:
|
||||
out.append(v) # first row: absolute position
|
||||
out.append(v) # first row: absolute position
|
||||
else:
|
||||
out.append(v - prev) # subsequent rows: spacing from previous
|
||||
out.append(v - prev) # subsequent rows: spacing from previous
|
||||
prev = v
|
||||
return out
|
||||
|
||||
|
|
@ -169,23 +169,28 @@ class _AxisGridTable(QWidget):
|
|||
visible_widget = self._table.cellWidget(row, 3)
|
||||
bubble_widget = self._table.cellWidget(row, 4)
|
||||
color_item = self._table.item(row, 5)
|
||||
out.append(GridLine(
|
||||
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
|
||||
ordinate=ords[row] if row < len(ords) else 0.0,
|
||||
line_type=(
|
||||
line_type_widget.currentText() # type: ignore[union-attr]
|
||||
if isinstance(line_type_widget, QComboBox) else "Primary"
|
||||
),
|
||||
visible=(
|
||||
visible_widget.isChecked() # type: ignore[union-attr]
|
||||
if isinstance(visible_widget, QCheckBox) else True
|
||||
),
|
||||
bubble_loc=(
|
||||
bubble_widget.currentText() # type: ignore[union-attr]
|
||||
if isinstance(bubble_widget, QComboBox) else "End"
|
||||
),
|
||||
color=(color_item.text() if color_item else "#808080"),
|
||||
))
|
||||
out.append(
|
||||
GridLine(
|
||||
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
|
||||
ordinate=ords[row] if row < len(ords) else 0.0,
|
||||
line_type=(
|
||||
line_type_widget.currentText() # type: ignore[union-attr]
|
||||
if isinstance(line_type_widget, QComboBox)
|
||||
else "Primary"
|
||||
),
|
||||
visible=(
|
||||
visible_widget.isChecked() # type: ignore[union-attr]
|
||||
if isinstance(visible_widget, QCheckBox)
|
||||
else True
|
||||
),
|
||||
bubble_loc=(
|
||||
bubble_widget.currentText() # type: ignore[union-attr]
|
||||
if isinstance(bubble_widget, QComboBox)
|
||||
else "End"
|
||||
),
|
||||
color=(color_item.text() if color_item else "#808080"),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
# ── row ops ───────────────────────────────────────────────────
|
||||
|
|
@ -218,7 +223,8 @@ class _AxisGridTable(QWidget):
|
|||
vis = QCheckBox()
|
||||
vis.setChecked(ln.visible)
|
||||
vis_wrap = QWidget()
|
||||
lay = QHBoxLayout(vis_wrap); lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay = QHBoxLayout(vis_wrap)
|
||||
lay.setContentsMargins(0, 0, 0, 0)
|
||||
lay.addWidget(vis)
|
||||
lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
# We keep the QCheckBox directly; the wrap is optional visual
|
||||
|
|
@ -250,13 +256,13 @@ class _AxisGridTable(QWidget):
|
|||
lines = self.collect_lines()
|
||||
lines.sort(key=lambda ln: ln.ordinate)
|
||||
self._table.setRowCount(0)
|
||||
self._show_spacing = False # reset to ordinate view after sort
|
||||
self._show_spacing = False # reset to ordinate view after sort
|
||||
self._table.setHorizontalHeaderLabels(self.COLUMNS)
|
||||
for ln in lines:
|
||||
self._append_row(ln)
|
||||
|
||||
def _on_cell_double_clicked(self, row: int, col: int) -> None:
|
||||
if col == 5: # Color picker
|
||||
if col == 5: # Color picker
|
||||
item = self._table.item(row, col)
|
||||
current = QColor(item.text() if item else "#808080")
|
||||
new = QColorDialog.getColor(current, self, "Grid Line Color")
|
||||
|
|
@ -278,8 +284,7 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(
|
||||
"Define Grid System Data" if existing is not None
|
||||
else "Add Grid System"
|
||||
"Define Grid System Data" if existing is not None else "Add Grid System"
|
||||
)
|
||||
self.resize(780, 620)
|
||||
self._is_global = is_global
|
||||
|
|
@ -328,9 +333,7 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
self._btn_locate.clicked.connect(self._on_locate_origin)
|
||||
if self._is_global:
|
||||
self._btn_locate.setEnabled(False)
|
||||
self._btn_locate.setToolTip(
|
||||
"Global system is anchored at the world origin."
|
||||
)
|
||||
self._btn_locate.setToolTip("Global system is anchored at the world origin.")
|
||||
head.addWidget(self._btn_locate)
|
||||
root.addLayout(head)
|
||||
|
||||
|
|
@ -375,8 +378,7 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
bottom.addWidget(self._btn_quick)
|
||||
bottom.addStretch(1)
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
@ -388,8 +390,7 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
ox, oy, oz = self._origin
|
||||
rx, ry, rz = self._rotation_deg
|
||||
self._origin_label.setText(
|
||||
f"Origin: ({ox:g}, {oy:g}, {oz:g}) "
|
||||
f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
|
||||
f"Origin: ({ox:g}, {oy:g}, {oz:g}) " f"Rotation: ({rx:g}°, {ry:g}°, {rz:g}°)"
|
||||
)
|
||||
|
||||
def _load_existing(self, cs: CoordinateGridSystem) -> None:
|
||||
|
|
@ -415,6 +416,7 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
from otko.views.dialogs.locate_origin import (
|
||||
CoordSystemLocationOrientationDialog,
|
||||
)
|
||||
|
||||
dlg = CoordSystemLocationOrientationDialog(
|
||||
origin=self._origin,
|
||||
rotation_deg=self._rotation_deg,
|
||||
|
|
@ -429,11 +431,13 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
from otko.views.dialogs.quick_grid_lines import (
|
||||
QuickGridLinesDialog,
|
||||
)
|
||||
|
||||
dlg = QuickGridLinesDialog(parent=self)
|
||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
xs, ys, zs = dlg.ordinates()
|
||||
from otko.core.geometry import make_grid_lines
|
||||
|
||||
# Revert to ordinate display mode before replacing rows.
|
||||
self._rb_ordinates.setChecked(True)
|
||||
self._tab_x.load_lines(make_grid_lines("X", xs))
|
||||
|
|
@ -465,7 +469,8 @@ class DefineGridSystemDataDialog(QDialog):
|
|||
return CoordinateGridSystem(
|
||||
name=self._name_edit.text().strip(),
|
||||
coord=CoordinateSystem(
|
||||
origin=self._origin, rotation_deg=self._rotation_deg,
|
||||
origin=self._origin,
|
||||
rotation_deg=self._rotation_deg,
|
||||
),
|
||||
grid=grid,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -141,9 +141,11 @@ class AssignDistributedLoadDialog(QDialog):
|
|||
|
||||
def values(self) -> tuple[float, float, float]:
|
||||
"""Return (wy, wz, wx)."""
|
||||
return (self._spinboxes["wy"].value(),
|
||||
self._spinboxes["wz"].value(),
|
||||
self._spinboxes["wx"].value())
|
||||
return (
|
||||
self._spinboxes["wy"].value(),
|
||||
self._spinboxes["wz"].value(),
|
||||
self._spinboxes["wx"].value(),
|
||||
)
|
||||
|
||||
def selected_pattern_id(self) -> int | None:
|
||||
"""Return the chosen pattern id, or ``None`` if the user picked
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ def _coords_from_spacings(spacings: list[float], origin: float = 0.0) -> list[fl
|
|||
class GridSystemDialog(QDialog):
|
||||
"""Dialog for entering X/Y/Z grid-line coordinates."""
|
||||
|
||||
def __init__(self, next_node_id: int,
|
||||
existing: GridSystem | None = None,
|
||||
parent: QWidget | None = None) -> None:
|
||||
def __init__(
|
||||
self, next_node_id: int, existing: GridSystem | None = None, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Grid System")
|
||||
self._next_id = next_node_id
|
||||
|
|
@ -104,9 +104,7 @@ class GridSystemDialog(QDialog):
|
|||
self._visible_cb.setChecked(True)
|
||||
layout.addWidget(self._visible_cb)
|
||||
|
||||
self._generate_nodes_cb = QCheckBox(
|
||||
"Also create nodes at every intersection"
|
||||
)
|
||||
self._generate_nodes_cb = QCheckBox("Also create nodes at every intersection")
|
||||
self._generate_nodes_cb.setChecked(False)
|
||||
layout.addWidget(self._generate_nodes_cb)
|
||||
|
||||
|
|
@ -128,6 +126,7 @@ class GridSystemDialog(QDialog):
|
|||
|
||||
def _load_existing(self, grid: GridSystem) -> None:
|
||||
"""Pre-fill editors from an existing GridSystem (as spacings)."""
|
||||
|
||||
def to_spacings_text(coords: list[float]) -> str:
|
||||
if not coords:
|
||||
return ""
|
||||
|
|
@ -136,6 +135,7 @@ class GridSystemDialog(QDialog):
|
|||
# Single line at nonzero origin — degenerate, show empty.
|
||||
return ""
|
||||
return ", ".join(f"{s:g}" for s in spacings)
|
||||
|
||||
self._x_edit.setText(to_spacings_text(grid.x_lines))
|
||||
self._y_edit.setText(to_spacings_text(grid.y_lines))
|
||||
self._z_edit.setText(to_spacings_text(grid.z_lines))
|
||||
|
|
@ -152,8 +152,7 @@ class GridSystemDialog(QDialog):
|
|||
else:
|
||||
extra = "<br>Nodes will NOT be created automatically."
|
||||
self._preview.setText(
|
||||
f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines."
|
||||
f"{extra}"
|
||||
f"Grid: <b>{len(xs)}</b> × <b>{len(ys)}</b> × <b>{len(zs)}</b> lines." f"{extra}"
|
||||
)
|
||||
except (ValueError, IndexError) as exc:
|
||||
self._preview.setText(f"<span style='color:red'>Parse error: {exc}</span>")
|
||||
|
|
|
|||
|
|
@ -36,28 +36,35 @@ class CoordSystemLocationOrientationDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel(
|
||||
"Enter the system's origin and orientation relative to <b>Global</b>. "
|
||||
"Rotations are XYZ Euler angles in degrees."
|
||||
))
|
||||
root.addWidget(
|
||||
QLabel(
|
||||
"Enter the system's origin and orientation relative to <b>Global</b>. "
|
||||
"Rotations are XYZ Euler angles in degrees."
|
||||
)
|
||||
)
|
||||
|
||||
origin_box = QGroupBox("Origin (world units)")
|
||||
of = QFormLayout(origin_box)
|
||||
self._ox = self._spin(); of.addRow("X:", self._ox)
|
||||
self._oy = self._spin(); of.addRow("Y:", self._oy)
|
||||
self._oz = self._spin(); of.addRow("Z:", self._oz)
|
||||
self._ox = self._spin()
|
||||
of.addRow("X:", self._ox)
|
||||
self._oy = self._spin()
|
||||
of.addRow("Y:", self._oy)
|
||||
self._oz = self._spin()
|
||||
of.addRow("Z:", self._oz)
|
||||
root.addWidget(origin_box)
|
||||
|
||||
rot_box = QGroupBox("Rotation about axes (degrees)")
|
||||
rf = QFormLayout(rot_box)
|
||||
self._rx = self._rot_spin(); rf.addRow("about X:", self._rx)
|
||||
self._ry = self._rot_spin(); rf.addRow("about Y:", self._ry)
|
||||
self._rz = self._rot_spin(); rf.addRow("about Z:", self._rz)
|
||||
self._rx = self._rot_spin()
|
||||
rf.addRow("about X:", self._rx)
|
||||
self._ry = self._rot_spin()
|
||||
rf.addRow("about Y:", self._ry)
|
||||
self._rz = self._rot_spin()
|
||||
rf.addRow("about Z:", self._rz)
|
||||
root.addWidget(rot_box)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
|
|||
|
|
@ -33,10 +33,16 @@ from otko.core import (
|
|||
|
||||
|
||||
# ─────────────────────────── helpers ───────────────────────────
|
||||
def _spin(default: float = 0.0, *, decimals: int = 6,
|
||||
minimum: float = -1e15, maximum: float = 1e15,
|
||||
step: float = 1.0) -> QDoubleSpinBox:
|
||||
def _spin(
|
||||
default: float = 0.0,
|
||||
*,
|
||||
decimals: int = 6,
|
||||
minimum: float = -1e15,
|
||||
maximum: float = 1e15,
|
||||
step: float = 1.0,
|
||||
) -> QDoubleSpinBox:
|
||||
from PySide6.QtCore import QLocale
|
||||
|
||||
sb = QDoubleSpinBox()
|
||||
# Force C locale so "." is always the decimal separator.
|
||||
sb.setLocale(QLocale(QLocale.Language.C))
|
||||
|
|
@ -110,8 +116,11 @@ class Steel01Form(MaterialFormBase):
|
|||
|
||||
def _read_specific(self, mid: int) -> Steel01:
|
||||
return Steel01(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
Fy=self._fy.value(),
|
||||
E0=self._e0.value(),
|
||||
b=self._b.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -127,19 +136,34 @@ class Steel02Form(MaterialFormBase):
|
|||
self._r0 = _spin(18.0, decimals=2, minimum=10.0, maximum=20.0, step=0.5)
|
||||
self._cR1 = _spin(0.925, decimals=4, step=0.01)
|
||||
self._cR2 = _spin(0.15, decimals=4, step=0.01)
|
||||
for label, w in (("Fy:", self._fy), ("E0:", self._e0), ("b:", self._b),
|
||||
("R0:", self._r0), ("cR1:", self._cR1), ("cR2:", self._cR2)):
|
||||
for label, w in (
|
||||
("Fy:", self._fy),
|
||||
("E0:", self._e0),
|
||||
("b:", self._b),
|
||||
("R0:", self._r0),
|
||||
("cR1:", self._cR1),
|
||||
("cR2:", self._cR2),
|
||||
):
|
||||
self._layout.addRow(label, w)
|
||||
|
||||
def _populate_specific(self, m: Steel02) -> None:
|
||||
self._fy.setValue(m.Fy); self._e0.setValue(m.E0); self._b.setValue(m.b)
|
||||
self._r0.setValue(m.R0); self._cR1.setValue(m.cR1); self._cR2.setValue(m.cR2)
|
||||
self._fy.setValue(m.Fy)
|
||||
self._e0.setValue(m.E0)
|
||||
self._b.setValue(m.b)
|
||||
self._r0.setValue(m.R0)
|
||||
self._cR1.setValue(m.cR1)
|
||||
self._cR2.setValue(m.cR2)
|
||||
|
||||
def _read_specific(self, mid: int) -> Steel02:
|
||||
return Steel02(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
Fy=self._fy.value(), E0=self._e0.value(), b=self._b.value(),
|
||||
R0=self._r0.value(), cR1=self._cR1.value(), cR2=self._cR2.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
Fy=self._fy.value(),
|
||||
E0=self._e0.value(),
|
||||
b=self._b.value(),
|
||||
R0=self._r0.value(),
|
||||
cR1=self._cR1.value(),
|
||||
cR2=self._cR2.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -156,19 +180,28 @@ class Concrete01Form(MaterialFormBase):
|
|||
self._epsc0 = _spin(-0.002, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
|
||||
self._fpcu = _spin(-15e6, step=1.0, maximum=0.0)
|
||||
self._epsU = _spin(-0.005, decimals=6, minimum=-1.0, maximum=0.0, step=1e-4)
|
||||
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
|
||||
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU)):
|
||||
for label, w in (
|
||||
("f'c (-):", self._fpc),
|
||||
("ε_c0 (-):", self._epsc0),
|
||||
("f'cu (-):", self._fpcu),
|
||||
("ε_U (-):", self._epsU),
|
||||
):
|
||||
self._layout.addRow(label, w)
|
||||
|
||||
def _populate_specific(self, m: Concrete01) -> None:
|
||||
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
|
||||
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
|
||||
self._fpc.setValue(m.fpc)
|
||||
self._epsc0.setValue(m.epsc0)
|
||||
self._fpcu.setValue(m.fpcu)
|
||||
self._epsU.setValue(m.epsU)
|
||||
|
||||
def _read_specific(self, mid: int) -> Concrete01:
|
||||
return Concrete01(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
|
||||
fpcu=self._fpcu.value(), epsU=self._epsU.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(),
|
||||
epsc0=self._epsc0.value(),
|
||||
fpcu=self._fpcu.value(),
|
||||
epsU=self._epsU.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -187,23 +220,36 @@ class Concrete02Form(MaterialFormBase):
|
|||
self._lambda = _spin(0.1, decimals=4, minimum=0.0, maximum=1.0, step=0.01)
|
||||
self._ft = _spin(3e6, step=1.0, minimum=1e-9)
|
||||
self._ets = _spin(2e9, step=1.0, minimum=1e-9)
|
||||
for label, w in (("f'c (-):", self._fpc), ("ε_c0 (-):", self._epsc0),
|
||||
("f'cu (-):", self._fpcu), ("ε_U (-):", self._epsU),
|
||||
("λ (unload ratio):", self._lambda),
|
||||
("ft (tensile):", self._ft), ("Ets (soften):", self._ets)):
|
||||
for label, w in (
|
||||
("f'c (-):", self._fpc),
|
||||
("ε_c0 (-):", self._epsc0),
|
||||
("f'cu (-):", self._fpcu),
|
||||
("ε_U (-):", self._epsU),
|
||||
("λ (unload ratio):", self._lambda),
|
||||
("ft (tensile):", self._ft),
|
||||
("Ets (soften):", self._ets),
|
||||
):
|
||||
self._layout.addRow(label, w)
|
||||
|
||||
def _populate_specific(self, m: Concrete02) -> None:
|
||||
self._fpc.setValue(m.fpc); self._epsc0.setValue(m.epsc0)
|
||||
self._fpcu.setValue(m.fpcu); self._epsU.setValue(m.epsU)
|
||||
self._lambda.setValue(m.lambda_); self._ft.setValue(m.ft); self._ets.setValue(m.Ets)
|
||||
self._fpc.setValue(m.fpc)
|
||||
self._epsc0.setValue(m.epsc0)
|
||||
self._fpcu.setValue(m.fpcu)
|
||||
self._epsU.setValue(m.epsU)
|
||||
self._lambda.setValue(m.lambda_)
|
||||
self._ft.setValue(m.ft)
|
||||
self._ets.setValue(m.Ets)
|
||||
|
||||
def _read_specific(self, mid: int) -> Concrete02:
|
||||
return Concrete02(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
|
||||
fpcu=self._fpcu.value(), epsU=self._epsU.value(),
|
||||
ft=self._ft.value(), Ets=self._ets.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(),
|
||||
epsc0=self._epsc0.value(),
|
||||
fpcu=self._fpcu.value(),
|
||||
epsU=self._epsU.value(),
|
||||
ft=self._ft.value(),
|
||||
Ets=self._ets.value(),
|
||||
**{"lambda": self._lambda.value()},
|
||||
)
|
||||
|
||||
|
|
@ -243,9 +289,12 @@ class Concrete04Form(MaterialFormBase):
|
|||
fct_val = self._fct.value()
|
||||
et_val = self._et.value()
|
||||
return Concrete04(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(), epsc0=self._epsc0.value(),
|
||||
epscu=self._epscu.value(), Ec=self._Ec.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
fpc=self._fpc.value(),
|
||||
epsc0=self._epsc0.value(),
|
||||
epscu=self._epscu.value(),
|
||||
Ec=self._Ec.value(),
|
||||
fct=fct_val if fct_val > 0.0 else None,
|
||||
et=et_val if et_val > 0.0 else None,
|
||||
)
|
||||
|
|
@ -263,12 +312,15 @@ class ElasticUniaxialForm(MaterialFormBase):
|
|||
self._layout.addRow("η (damping):", self._eta)
|
||||
|
||||
def _populate_specific(self, m: ElasticUniaxial) -> None:
|
||||
self._e.setValue(m.E); self._eta.setValue(m.eta)
|
||||
self._e.setValue(m.E)
|
||||
self._eta.setValue(m.eta)
|
||||
|
||||
def _read_specific(self, mid: int) -> ElasticUniaxial:
|
||||
return ElasticUniaxial(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
E=self._e.value(), eta=self._eta.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
E=self._e.value(),
|
||||
eta=self._eta.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -286,12 +338,17 @@ class ElasticIsotropicForm(MaterialFormBase):
|
|||
self._layout.addRow("ρ (density):", self._rho)
|
||||
|
||||
def _populate_specific(self, m: ElasticIsotropic) -> None:
|
||||
self._e.setValue(m.E); self._nu.setValue(m.nu); self._rho.setValue(m.rho)
|
||||
self._e.setValue(m.E)
|
||||
self._nu.setValue(m.nu)
|
||||
self._rho.setValue(m.rho)
|
||||
|
||||
def _read_specific(self, mid: int) -> ElasticIsotropic:
|
||||
return ElasticIsotropic(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
E=self._e.value(), nu=self._nu.value(), rho=self._rho.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
E=self._e.value(),
|
||||
nu=self._nu.value(),
|
||||
rho=self._rho.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -307,12 +364,15 @@ class ElasticPPForm(MaterialFormBase):
|
|||
self._layout.addRow("ε_y (yield strain):", self._epsy)
|
||||
|
||||
def _populate_specific(self, m: ElasticPP) -> None:
|
||||
self._e.setValue(m.E); self._epsy.setValue(m.epsy_pos)
|
||||
self._e.setValue(m.E)
|
||||
self._epsy.setValue(m.epsy_pos)
|
||||
|
||||
def _read_specific(self, mid: int) -> ElasticPP:
|
||||
return ElasticPP(
|
||||
id=mid, name=self._name_edit.text(),
|
||||
E=self._e.value(), epsy_pos=self._epsy.value(),
|
||||
id=mid,
|
||||
name=self._name_edit.text(),
|
||||
E=self._e.value(),
|
||||
epsy_pos=self._epsy.value(),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,12 @@ class MaterialLibraryDialog(QDialog):
|
|||
return
|
||||
kinds = list(FORM_REGISTRY.keys())
|
||||
kind, ok = QInputDialog.getItem(
|
||||
self, "Add material", "Type:", kinds, current=0, editable=False,
|
||||
self,
|
||||
"Add material",
|
||||
"Type:",
|
||||
kinds,
|
||||
current=0,
|
||||
editable=False,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
|
@ -179,9 +184,10 @@ class MaterialLibraryDialog(QDialog):
|
|||
if material is None:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete material",
|
||||
self,
|
||||
"Delete material",
|
||||
f"Delete material #{material.id} ({material.type})?\n"
|
||||
"Elements that reference it will be invalid until reassigned."
|
||||
"Elements that reference it will be invalid until reassigned.",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ class MirrorDialog(QDialog):
|
|||
|
||||
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
f"Mirror <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)."
|
||||
))
|
||||
layout.addWidget(QLabel(
|
||||
"<i>Only elements whose endpoints are both in the selection are copied.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel(f"Mirror <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).")
|
||||
)
|
||||
layout.addWidget(
|
||||
QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>")
|
||||
)
|
||||
|
||||
box = QGroupBox("Reflection plane (passes through origin)")
|
||||
box_layout = QVBoxLayout(box)
|
||||
|
|
|
|||
|
|
@ -44,11 +44,13 @@ class PathTimeSeriesDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel(
|
||||
"<b>Path TimeSeries</b> — tabulated values sampled at a "
|
||||
"uniform time step. Used by UniformExcitation (ground "
|
||||
"motion) and by PlainLoadPattern scaled forces."
|
||||
))
|
||||
root.addWidget(
|
||||
QLabel(
|
||||
"<b>Path TimeSeries</b> — tabulated values sampled at a "
|
||||
"uniform time step. Used by UniformExcitation (ground "
|
||||
"motion) and by PlainLoadPattern scaled forces."
|
||||
)
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
self._name_edit = QLineEdit("GroundMotion")
|
||||
|
|
@ -65,7 +67,7 @@ class PathTimeSeriesDialog(QDialog):
|
|||
self._factor_spin.setRange(-1e12, 1e12)
|
||||
self._factor_spin.setDecimals(6)
|
||||
self._factor_spin.setSingleStep(1.0)
|
||||
self._factor_spin.setValue(386.4) # default: convert g → in/s²
|
||||
self._factor_spin.setValue(386.4) # default: convert g → in/s²
|
||||
self._factor_spin.setToolTip(
|
||||
"Multiplier applied to every value at runtime. Typical use: "
|
||||
"386.4 for ground motion in g → in/s² (US_IN_KIP), 9.81 for "
|
||||
|
|
@ -84,9 +86,7 @@ class PathTimeSeriesDialog(QDialog):
|
|||
btn_row.addWidget(self._btn_plain)
|
||||
root.addLayout(btn_row)
|
||||
|
||||
self._status = QLabel(
|
||||
"<i>No data loaded — use one of the import buttons.</i>"
|
||||
)
|
||||
self._status = QLabel("<i>No data loaded — use one of the import buttons.</i>")
|
||||
self._status.setWordWrap(True)
|
||||
self._status.setStyleSheet("color: #666;")
|
||||
root.addWidget(self._status)
|
||||
|
|
@ -102,8 +102,7 @@ class PathTimeSeriesDialog(QDialog):
|
|||
root.addWidget(self._preview, 1)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self._on_accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
@ -112,8 +111,10 @@ class PathTimeSeriesDialog(QDialog):
|
|||
# ── file-import slots ───────────────────────────────────────────
|
||||
def _on_import_peer(self) -> None:
|
||||
fname, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import PEER record",
|
||||
"", "PEER records (*.at2 *.AT2);;All files (*)",
|
||||
self,
|
||||
"Import PEER record",
|
||||
"",
|
||||
"PEER records (*.at2 *.AT2);;All files (*)",
|
||||
)
|
||||
if not fname:
|
||||
return
|
||||
|
|
@ -132,8 +133,10 @@ class PathTimeSeriesDialog(QDialog):
|
|||
|
||||
def _on_import_plain(self) -> None:
|
||||
fname, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import plain values",
|
||||
"", "Text files (*.txt *.csv *.dat);;All files (*)",
|
||||
self,
|
||||
"Import plain values",
|
||||
"",
|
||||
"Text files (*.txt *.csv *.dat);;All files (*)",
|
||||
)
|
||||
if not fname:
|
||||
return
|
||||
|
|
@ -144,8 +147,7 @@ class PathTimeSeriesDialog(QDialog):
|
|||
return
|
||||
self._values = vals
|
||||
self._status.setText(
|
||||
f"Loaded <b>{len(vals)}</b> values from plain-text file. "
|
||||
"Set Δt manually above."
|
||||
f"Loaded <b>{len(vals)}</b> values from plain-text file. " "Set Δt manually above."
|
||||
)
|
||||
self._refresh_preview()
|
||||
|
||||
|
|
@ -167,7 +169,9 @@ class PathTimeSeriesDialog(QDialog):
|
|||
def _on_accept(self) -> None:
|
||||
if not self._values:
|
||||
QMessageBox.warning(
|
||||
self, "No data", "Import a record first.",
|
||||
self,
|
||||
"No data",
|
||||
"Import a record first.",
|
||||
)
|
||||
return
|
||||
self.accept()
|
||||
|
|
|
|||
|
|
@ -31,39 +31,56 @@ class QuickGridLinesDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel(
|
||||
"<b>Quick-define a regular (Cartesian) grid.</b><br>"
|
||||
"Each axis: number of lines, equal spacing, first line coordinate."
|
||||
))
|
||||
root.addWidget(
|
||||
QLabel(
|
||||
"<b>Quick-define a regular (Cartesian) grid.</b><br>"
|
||||
"Each axis: number of lines, equal spacing, first line coordinate."
|
||||
)
|
||||
)
|
||||
|
||||
self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root)
|
||||
self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root)
|
||||
self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
|
||||
|
||||
# Sensible defaults.
|
||||
self._x_n.setValue(3); self._x_s.setValue(6.0); self._x_f.setValue(0.0)
|
||||
self._y_n.setValue(3); self._y_s.setValue(6.0); self._y_f.setValue(0.0)
|
||||
self._z_n.setValue(2); self._z_s.setValue(3.0); self._z_f.setValue(0.0)
|
||||
self._x_n.setValue(3)
|
||||
self._x_s.setValue(6.0)
|
||||
self._x_f.setValue(0.0)
|
||||
self._y_n.setValue(3)
|
||||
self._y_s.setValue(6.0)
|
||||
self._y_f.setValue(0.0)
|
||||
self._z_n.setValue(2)
|
||||
self._z_s.setValue(3.0)
|
||||
self._z_f.setValue(0.0)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _axis_group(
|
||||
self, title: str, parent_layout: QVBoxLayout,
|
||||
self,
|
||||
title: str,
|
||||
parent_layout: QVBoxLayout,
|
||||
) -> tuple[QSpinBox, QDoubleSpinBox, QDoubleSpinBox]:
|
||||
box = QGroupBox(title)
|
||||
row = QHBoxLayout(box)
|
||||
|
||||
n = QSpinBox(); n.setRange(0, 200); n.setValue(3)
|
||||
s = QDoubleSpinBox(); s.setRange(0.0, 1e6); s.setDecimals(4)
|
||||
s.setSingleStep(0.5); s.setValue(1.0)
|
||||
f = QDoubleSpinBox(); f.setRange(-1e6, 1e6); f.setDecimals(4)
|
||||
f.setSingleStep(0.5); f.setValue(0.0)
|
||||
n = QSpinBox()
|
||||
n.setRange(0, 200)
|
||||
n.setValue(3)
|
||||
s = QDoubleSpinBox()
|
||||
s.setRange(0.0, 1e6)
|
||||
s.setDecimals(4)
|
||||
s.setSingleStep(0.5)
|
||||
s.setValue(1.0)
|
||||
f = QDoubleSpinBox()
|
||||
f.setRange(-1e6, 1e6)
|
||||
f.setDecimals(4)
|
||||
f.setSingleStep(0.5)
|
||||
f.setValue(0.0)
|
||||
|
||||
row.addWidget(QLabel("Number of lines:"))
|
||||
row.addWidget(n)
|
||||
|
|
@ -76,8 +93,10 @@ class QuickGridLinesDialog(QDialog):
|
|||
|
||||
def ordinates(self) -> tuple[list[float], list[float], list[float]]:
|
||||
"""Return (xs, ys, zs) — flat lists of ordinate values."""
|
||||
|
||||
def axis(n: QSpinBox, s: QDoubleSpinBox, f: QDoubleSpinBox) -> list[float]:
|
||||
return [f.value() + i * s.value() for i in range(n.value())]
|
||||
|
||||
return (
|
||||
axis(self._x_n, self._x_s, self._x_f),
|
||||
axis(self._y_n, self._y_s, self._y_f),
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ with <b>Define → Add Plain Load Pattern…</b> or
|
|||
<b>Define → Add Uniform Excitation…</b> for seismic input.</p>
|
||||
|
||||
<h4>4 — Draw the model</h4>
|
||||
<p>Tools toolbar (left): <b>Select</b>, <b>Draw Node</b> (F1),
|
||||
<p>Tools toolbar (top): <b>Select</b>, <b>Draw Node</b> (F1),
|
||||
<b>Draw Frame</b> (F2), <b>Draw Truss</b> (F3, icon-less by design).
|
||||
<b>Define → Coordinate System/Grids…</b> (Ctrl+G) and <b>Add Node…</b> (Ctrl+N)
|
||||
for precise layout; use the top-toolbar <b>Level</b> combo in Top/Front/Right views.</p>
|
||||
|
|
|
|||
|
|
@ -42,12 +42,12 @@ class ReplicateDialog(QDialog):
|
|||
|
||||
def _build_ui(self, n_nodes: int, n_elements: int) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(QLabel(
|
||||
f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s)."
|
||||
))
|
||||
layout.addWidget(QLabel(
|
||||
"<i>Only elements whose endpoints are both in the selection are copied.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel(f"Replicate <b>{n_nodes}</b> node(s) and <b>{n_elements}</b> element(s).")
|
||||
)
|
||||
layout.addWidget(
|
||||
QLabel("<i>Only elements whose endpoints are both in the selection are copied.</i>")
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,18 @@ class RunAnalysisDialog(QDialog):
|
|||
row.addWidget(self._case_combo, stretch=1)
|
||||
layout.addLayout(row)
|
||||
|
||||
combo_row = QHBoxLayout()
|
||||
combo_row.addWidget(QLabel("Combination:"))
|
||||
self._combo_combo = QComboBox()
|
||||
combo_row.addWidget(self._combo_combo, stretch=1)
|
||||
self._eval_btn = QPushButton("Evaluate")
|
||||
self._eval_btn.setToolTip(
|
||||
"Run each referenced static case, then combine results "
|
||||
"in post-processing (linear superposition, no extra solver input)."
|
||||
)
|
||||
combo_row.addWidget(self._eval_btn)
|
||||
layout.addLayout(combo_row)
|
||||
|
||||
# Run-time damping overrides for transient cases only.
|
||||
self._damping_box = QGroupBox("Rayleigh damping (transient only)")
|
||||
form = QFormLayout(self._damping_box)
|
||||
|
|
@ -79,12 +91,14 @@ class RunAnalysisDialog(QDialog):
|
|||
self._mode1_damping.setSingleStep(0.01)
|
||||
form.addRow("Mode-1 damping zeta:", self._mode1_damping)
|
||||
|
||||
form.addRow(QLabel(
|
||||
"<i>Values are applied for this run only via a copy of the case — "
|
||||
"the stored case is not modified. If mode-1 damping is "
|
||||
"greater than zero, the runner computes betaK from the first "
|
||||
"mode after preload and overrides the manual betaK value.</i>",
|
||||
))
|
||||
form.addRow(
|
||||
QLabel(
|
||||
"<i>Values are applied for this run only via a copy of the case — "
|
||||
"the stored case is not modified. If mode-1 damping is "
|
||||
"greater than zero, the runner computes betaK from the first "
|
||||
"mode after preload and overrides the manual betaK value.</i>",
|
||||
)
|
||||
)
|
||||
layout.addWidget(self._damping_box)
|
||||
|
||||
self._progress = QProgressBar()
|
||||
|
|
@ -98,7 +112,8 @@ class RunAnalysisDialog(QDialog):
|
|||
layout.addWidget(self._log, stretch=1)
|
||||
|
||||
self._buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Close, parent=self,
|
||||
QDialogButtonBox.StandardButton.Close,
|
||||
parent=self,
|
||||
)
|
||||
self._run_btn = QPushButton("Run")
|
||||
self._buttons.addButton(self._run_btn, QDialogButtonBox.ButtonRole.ActionRole)
|
||||
|
|
@ -106,6 +121,7 @@ class RunAnalysisDialog(QDialog):
|
|||
|
||||
def _wire(self) -> None:
|
||||
self._run_btn.clicked.connect(self._on_run)
|
||||
self._eval_btn.clicked.connect(self._on_evaluate_combo)
|
||||
self._buttons.rejected.connect(self.reject)
|
||||
self._case_combo.currentIndexChanged.connect(self._on_case_changed)
|
||||
|
||||
|
|
@ -133,15 +149,21 @@ class RunAnalysisDialog(QDialog):
|
|||
|
||||
def _populate_cases(self) -> None:
|
||||
self._case_combo.clear()
|
||||
self._combo_combo.clear()
|
||||
if self._vm.project is None:
|
||||
self._run_btn.setEnabled(False)
|
||||
self._eval_btn.setEnabled(False)
|
||||
self._damping_box.setVisible(False)
|
||||
return
|
||||
for case in self._vm.project.analyses:
|
||||
label = f"#{case.id} {case.name or '(unnamed)'} [{case.type}]"
|
||||
self._case_combo.addItem(label, userData=case.id)
|
||||
for combo in self._vm.project.combinations:
|
||||
label = f"#{combo.id} {combo.name or '(unnamed)'} [{combo.kind}]"
|
||||
self._combo_combo.addItem(label, userData=combo.id)
|
||||
has_cases = self._case_combo.count() > 0
|
||||
self._run_btn.setEnabled(has_cases)
|
||||
self._eval_btn.setEnabled(self._combo_combo.count() > 0)
|
||||
if not has_cases:
|
||||
# No transient case can be selected, so the run-only damping
|
||||
# overrides are meaningless — hide them alongside the disabled Run.
|
||||
|
|
@ -157,11 +179,13 @@ class RunAnalysisDialog(QDialog):
|
|||
return
|
||||
if case.type == "Transient":
|
||||
mode1 = float(self._mode1_damping.value())
|
||||
case = case.model_copy(update={
|
||||
"rayleigh_alpha_m": float(self._alpha_m.value()),
|
||||
"rayleigh_beta_k": float(self._beta_k.value()),
|
||||
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
|
||||
})
|
||||
case = case.model_copy(
|
||||
update={
|
||||
"rayleigh_alpha_m": float(self._alpha_m.value()),
|
||||
"rayleigh_beta_k": float(self._beta_k.value()),
|
||||
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
|
||||
}
|
||||
)
|
||||
self._results = None
|
||||
self._log.clear()
|
||||
results_dir: Path | None = None
|
||||
|
|
@ -172,6 +196,49 @@ class RunAnalysisDialog(QDialog):
|
|||
except Exception as exc: # noqa: BLE001
|
||||
self._log.appendPlainText(f"Could not start: {exc}")
|
||||
|
||||
def _on_evaluate_combo(self) -> None:
|
||||
"""Run each referenced static case, then combine in post-processing.
|
||||
|
||||
Synchronous (linear statics are fast): each case runs through
|
||||
:class:`OpenSeesRunner` on the GUI thread, then
|
||||
:func:`evaluate_combination` superposes the results. The combined
|
||||
output is forwarded through ``runner.finished`` so the main
|
||||
window displays it like any other result.
|
||||
"""
|
||||
from otko.services.combinations import evaluate_combination
|
||||
from otko.services.opensees_runner import OpenSeesRunner
|
||||
|
||||
if self._runner.is_running or self._vm.project is None:
|
||||
return
|
||||
combo_id = self._combo_combo.currentData()
|
||||
project = self._vm.project
|
||||
combo = next((c for c in project.combinations if c.id == combo_id), None)
|
||||
if combo is None:
|
||||
return
|
||||
self._results = None
|
||||
self._log.clear()
|
||||
self._log.appendPlainText(f"Evaluating combination '{combo.name}' [{combo.kind}] ...")
|
||||
try:
|
||||
case_by_id = {c.id: c for c in project.analyses}
|
||||
collected: dict[int, Any] = {}
|
||||
for item in combo.items:
|
||||
case = case_by_id.get(item.case_id)
|
||||
if case is None:
|
||||
raise ValueError(f"Combination references missing case id={item.case_id}.")
|
||||
self._log.appendPlainText(
|
||||
f" running case #{case.id} '{case.name}' × {item.factor:g} ..."
|
||||
)
|
||||
collected[item.case_id] = OpenSeesRunner(project).run(case)
|
||||
combined = evaluate_combination(collected, combo)
|
||||
self._results = combined
|
||||
self._log.appendPlainText(f"--- Done. Returned {type(combined).__name__}. ---")
|
||||
self._runner.finished.emit(combined)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
import traceback
|
||||
|
||||
self._log.appendPlainText("--- FAILED ---")
|
||||
self._log.appendPlainText(f"{exc}\n{traceback.format_exc()}")
|
||||
|
||||
def _on_started(self) -> None:
|
||||
self._log.appendPlainText("--- Analysis started ---")
|
||||
|
||||
|
|
@ -191,4 +258,6 @@ class RunAnalysisDialog(QDialog):
|
|||
self._progress.setVisible(running)
|
||||
# With zero cases Run must stay disabled even when idle.
|
||||
self._run_btn.setEnabled((not running) and self._case_combo.count() > 0)
|
||||
self._eval_btn.setEnabled((not running) and self._combo_combo.count() > 0)
|
||||
self._case_combo.setEnabled(not running)
|
||||
self._combo_combo.setEnabled(not running)
|
||||
|
|
|
|||
|
|
@ -45,22 +45,31 @@ from otko.services.section_properties import (
|
|||
)
|
||||
|
||||
_PATCH_COLORS = [
|
||||
"#4e79a7", "#f28e2b", "#e15759", "#76b7b2",
|
||||
"#59a14f", "#edc948", "#b07aa1", "#ff9da7",
|
||||
"#4e79a7",
|
||||
"#f28e2b",
|
||||
"#e15759",
|
||||
"#76b7b2",
|
||||
"#59a14f",
|
||||
"#edc948",
|
||||
"#b07aa1",
|
||||
"#ff9da7",
|
||||
]
|
||||
|
||||
|
||||
class FiberSectionEditor(QDialog):
|
||||
"""Modal dialog: build a FiberSection from patches + layers."""
|
||||
|
||||
def __init__(self, material_ids: list[int],
|
||||
existing: FiberSection | None = None,
|
||||
parent: QWidget | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
material_ids: list[int],
|
||||
existing: FiberSection | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Fiber Section Editor")
|
||||
self.resize(1000, 650)
|
||||
self._material_ids = material_ids
|
||||
self._patches: list[Any] = [] # RectangularPatch | CircularPatch
|
||||
self._patches: list[Any] = [] # RectangularPatch | CircularPatch
|
||||
self._layers: list[StraightLayer] = []
|
||||
self._section_id: int = existing.id if existing else 1
|
||||
self._section_name: str = existing.name if existing else ""
|
||||
|
|
@ -106,25 +115,37 @@ class FiberSectionEditor(QDialog):
|
|||
pf.addRow("Material:", self._patch_mat)
|
||||
|
||||
# Rect fields
|
||||
self._rect_yi = self._spin(-0.15); self._rect_zi = self._spin(-0.15)
|
||||
self._rect_yj = self._spin(0.15); self._rect_zj = self._spin(0.15)
|
||||
self._rect_ny = self._ispin(8); self._rect_nz = self._ispin(8)
|
||||
self._rect_yi = self._spin(-0.15)
|
||||
self._rect_zi = self._spin(-0.15)
|
||||
self._rect_yj = self._spin(0.15)
|
||||
self._rect_zj = self._spin(0.15)
|
||||
self._rect_ny = self._ispin(8)
|
||||
self._rect_nz = self._ispin(8)
|
||||
self._rect_rows = [
|
||||
("y_i:", self._rect_yi), ("z_i:", self._rect_zi),
|
||||
("y_j:", self._rect_yj), ("z_j:", self._rect_zj),
|
||||
("n_fib_y:", self._rect_ny), ("n_fib_z:", self._rect_nz),
|
||||
("y_i:", self._rect_yi),
|
||||
("z_i:", self._rect_zi),
|
||||
("y_j:", self._rect_yj),
|
||||
("z_j:", self._rect_zj),
|
||||
("n_fib_y:", self._rect_ny),
|
||||
("n_fib_z:", self._rect_nz),
|
||||
]
|
||||
for label, widget in self._rect_rows:
|
||||
pf.addRow(label, widget)
|
||||
|
||||
# Circ fields (initially hidden)
|
||||
self._circ_yc = self._spin(0.0); self._circ_zc = self._spin(0.0)
|
||||
self._circ_ri = self._spin(0.0); self._circ_ro = self._spin(0.15)
|
||||
self._circ_nc = self._ispin(16); self._circ_nr = self._ispin(4)
|
||||
self._circ_yc = self._spin(0.0)
|
||||
self._circ_zc = self._spin(0.0)
|
||||
self._circ_ri = self._spin(0.0)
|
||||
self._circ_ro = self._spin(0.15)
|
||||
self._circ_nc = self._ispin(16)
|
||||
self._circ_nr = self._ispin(4)
|
||||
self._circ_rows = [
|
||||
("y_center:", self._circ_yc), ("z_center:", self._circ_zc),
|
||||
("r_inner:", self._circ_ri), ("r_outer:", self._circ_ro),
|
||||
("n_circ:", self._circ_nc), ("n_rad:", self._circ_nr),
|
||||
("y_center:", self._circ_yc),
|
||||
("z_center:", self._circ_zc),
|
||||
("r_inner:", self._circ_ri),
|
||||
("r_outer:", self._circ_ro),
|
||||
("n_circ:", self._circ_nc),
|
||||
("n_rad:", self._circ_nr),
|
||||
]
|
||||
for label, widget in self._circ_rows:
|
||||
pf.addRow(label, widget)
|
||||
|
|
@ -145,12 +166,16 @@ class FiberSectionEditor(QDialog):
|
|||
lf.addRow("Material:", self._layer_mat)
|
||||
self._layer_nbars = self._ispin(4)
|
||||
self._layer_area = self._spin(0.0005, step=0.0001, minimum=1e-12)
|
||||
self._layer_ys = self._spin(-0.12); self._layer_zs = self._spin(-0.12)
|
||||
self._layer_ye = self._spin(0.12); self._layer_ze = self._spin(-0.12)
|
||||
self._layer_ys = self._spin(-0.12)
|
||||
self._layer_zs = self._spin(-0.12)
|
||||
self._layer_ye = self._spin(0.12)
|
||||
self._layer_ze = self._spin(-0.12)
|
||||
lf.addRow("n_bars:", self._layer_nbars)
|
||||
lf.addRow("bar_area:", self._layer_area)
|
||||
lf.addRow("y_start:", self._layer_ys); lf.addRow("z_start:", self._layer_zs)
|
||||
lf.addRow("y_end:", self._layer_ye); lf.addRow("z_end:", self._layer_ze)
|
||||
lf.addRow("y_start:", self._layer_ys)
|
||||
lf.addRow("z_start:", self._layer_zs)
|
||||
lf.addRow("y_end:", self._layer_ye)
|
||||
lf.addRow("z_end:", self._layer_ze)
|
||||
self._add_layer_btn = QPushButton("Add layer")
|
||||
self._add_layer_btn.clicked.connect(self._on_add_layer)
|
||||
lf.addRow(self._add_layer_btn)
|
||||
|
|
@ -197,8 +222,7 @@ class FiberSectionEditor(QDialog):
|
|||
|
||||
# ── helpers ─────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _spin(default: float = 0.0, *, step: float = 0.01,
|
||||
minimum: float = -1e6) -> QDoubleSpinBox:
|
||||
def _spin(default: float = 0.0, *, step: float = 0.01, minimum: float = -1e6) -> QDoubleSpinBox:
|
||||
sb = QDoubleSpinBox()
|
||||
sb.setRange(minimum, 1e6)
|
||||
sb.setDecimals(6)
|
||||
|
|
@ -214,7 +238,7 @@ class FiberSectionEditor(QDialog):
|
|||
return sb
|
||||
|
||||
def _on_patch_type_changed(self, idx: int) -> None:
|
||||
is_rect = (idx == 0)
|
||||
is_rect = idx == 0
|
||||
for _, w in self._rect_rows:
|
||||
w.setVisible(is_rect)
|
||||
for _, w in self._circ_rows:
|
||||
|
|
@ -226,28 +250,38 @@ class FiberSectionEditor(QDialog):
|
|||
if self._patch_type.currentIndex() == 0:
|
||||
p = RectangularPatch(
|
||||
material_id=mid,
|
||||
n_fib_y=self._rect_ny.value(), n_fib_z=self._rect_nz.value(),
|
||||
y_i=self._rect_yi.value(), z_i=self._rect_zi.value(),
|
||||
y_j=self._rect_yj.value(), z_j=self._rect_zj.value(),
|
||||
n_fib_y=self._rect_ny.value(),
|
||||
n_fib_z=self._rect_nz.value(),
|
||||
y_i=self._rect_yi.value(),
|
||||
z_i=self._rect_zi.value(),
|
||||
y_j=self._rect_yj.value(),
|
||||
z_j=self._rect_zj.value(),
|
||||
)
|
||||
self._patches.append(p)
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Rect patch mat={mid} "
|
||||
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
|
||||
f"{p.n_fib_y}×{p.n_fib_z}",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Rect patch mat={mid} "
|
||||
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
|
||||
f"{p.n_fib_y}×{p.n_fib_z}",
|
||||
)
|
||||
)
|
||||
else:
|
||||
p = CircularPatch(
|
||||
material_id=mid,
|
||||
n_fib_circ=self._circ_nc.value(), n_fib_rad=self._circ_nr.value(),
|
||||
y_center=self._circ_yc.value(), z_center=self._circ_zc.value(),
|
||||
r_inner=self._circ_ri.value(), r_outer=self._circ_ro.value(),
|
||||
n_fib_circ=self._circ_nc.value(),
|
||||
n_fib_rad=self._circ_nr.value(),
|
||||
y_center=self._circ_yc.value(),
|
||||
z_center=self._circ_zc.value(),
|
||||
r_inner=self._circ_ri.value(),
|
||||
r_outer=self._circ_ro.value(),
|
||||
)
|
||||
self._patches.append(p)
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Circ patch mat={mid} "
|
||||
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Circ patch mat={mid} "
|
||||
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
|
||||
)
|
||||
)
|
||||
self._refresh_preview()
|
||||
|
||||
def _on_add_layer(self) -> None:
|
||||
|
|
@ -256,13 +290,17 @@ class FiberSectionEditor(QDialog):
|
|||
material_id=mid,
|
||||
n_bars=self._layer_nbars.value(),
|
||||
bar_area=self._layer_area.value(),
|
||||
y_start=self._layer_ys.value(), z_start=self._layer_zs.value(),
|
||||
y_end=self._layer_ye.value(), z_end=self._layer_ze.value(),
|
||||
y_start=self._layer_ys.value(),
|
||||
z_start=self._layer_zs.value(),
|
||||
y_end=self._layer_ye.value(),
|
||||
z_end=self._layer_ze.value(),
|
||||
)
|
||||
self._layers.append(lay)
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
|
||||
)
|
||||
)
|
||||
self._refresh_preview()
|
||||
|
||||
def _on_remove(self) -> None:
|
||||
|
|
@ -280,8 +318,7 @@ class FiberSectionEditor(QDialog):
|
|||
# ── preview ─────────────────────────────────────────────────────
|
||||
def _refresh_preview(self) -> None:
|
||||
self._preview.clear()
|
||||
sec = FiberSection(id=999999, patches=list(self._patches),
|
||||
layers=list(self._layers))
|
||||
sec = FiberSection(id=999999, patches=list(self._patches), layers=list(self._layers))
|
||||
props = compute_section_props(sec)
|
||||
if props.n_fibres == 0:
|
||||
self._props_label.setText("Add patches or layers to see the preview.")
|
||||
|
|
@ -295,8 +332,9 @@ class FiberSectionEditor(QDialog):
|
|||
for lay in self._layers:
|
||||
mat_ids.append(lay.material_id)
|
||||
unique_mats = sorted(set(mat_ids)) if mat_ids else [1]
|
||||
mat_to_color = {mid: _PATCH_COLORS[i % len(_PATCH_COLORS)]
|
||||
for i, mid in enumerate(unique_mats)}
|
||||
mat_to_color = {
|
||||
mid: _PATCH_COLORS[i % len(_PATCH_COLORS)] for i, mid in enumerate(unique_mats)
|
||||
}
|
||||
|
||||
# Draw patch fibres as squares, layer fibres as circles.
|
||||
# Expand per-patch for color assignment.
|
||||
|
|
@ -307,9 +345,13 @@ class FiberSectionEditor(QDialog):
|
|||
continue
|
||||
color = mat_to_color.get(p.material_id, "#888888")
|
||||
self._preview.plot(
|
||||
sub[:, 0], sub[:, 1],
|
||||
pen=None, symbol="s", symbolSize=6,
|
||||
symbolBrush=color, symbolPen=None,
|
||||
sub[:, 0],
|
||||
sub[:, 1],
|
||||
pen=None,
|
||||
symbol="s",
|
||||
symbolSize=6,
|
||||
symbolBrush=color,
|
||||
symbolPen=None,
|
||||
)
|
||||
for lay in self._layers:
|
||||
sub_sec = FiberSection(id=999999, layers=[lay])
|
||||
|
|
@ -318,16 +360,24 @@ class FiberSectionEditor(QDialog):
|
|||
continue
|
||||
color = mat_to_color.get(lay.material_id, "#ff0000")
|
||||
self._preview.plot(
|
||||
sub[:, 0], sub[:, 1],
|
||||
pen=None, symbol="o", symbolSize=8,
|
||||
symbolBrush=color, symbolPen=pg.mkPen("#ffffff", width=1),
|
||||
sub[:, 0],
|
||||
sub[:, 1],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=8,
|
||||
symbolBrush=color,
|
||||
symbolPen=pg.mkPen("#ffffff", width=1),
|
||||
)
|
||||
|
||||
# Centroid marker
|
||||
self._preview.plot(
|
||||
[props.centroid_y], [props.centroid_z],
|
||||
pen=None, symbol="+", symbolSize=16,
|
||||
symbolBrush=None, symbolPen=pg.mkPen("#ff0000", width=2),
|
||||
[props.centroid_y],
|
||||
[props.centroid_z],
|
||||
pen=None,
|
||||
symbol="+",
|
||||
symbolSize=16,
|
||||
symbolBrush=None,
|
||||
symbolPen=pg.mkPen("#ff0000", width=2),
|
||||
)
|
||||
|
||||
self._props_label.setText(
|
||||
|
|
@ -341,16 +391,22 @@ class FiberSectionEditor(QDialog):
|
|||
for p in sec.patches:
|
||||
self._patches.append(p)
|
||||
if isinstance(p, RectangularPatch):
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
|
||||
)
|
||||
)
|
||||
elif isinstance(p, CircularPatch):
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
|
||||
)
|
||||
)
|
||||
for lay in sec.layers:
|
||||
if isinstance(lay, StraightLayer):
|
||||
self._layers.append(lay)
|
||||
self._item_list.addItem(QListWidgetItem(
|
||||
f"Layer mat={lay.material_id} {lay.n_bars} bars",
|
||||
))
|
||||
self._item_list.addItem(
|
||||
QListWidgetItem(
|
||||
f"Layer mat={lay.material_id} {lay.n_bars} bars",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,9 +25,14 @@ from otko.core import (
|
|||
)
|
||||
|
||||
|
||||
def _spin(default: float = 0.0, *, decimals: int = 8,
|
||||
minimum: float = 1e-12, maximum: float = 1e15,
|
||||
step: float = 1.0) -> QDoubleSpinBox:
|
||||
def _spin(
|
||||
default: float = 0.0,
|
||||
*,
|
||||
decimals: int = 8,
|
||||
minimum: float = 1e-12,
|
||||
maximum: float = 1e15,
|
||||
step: float = 1.0,
|
||||
) -> QDoubleSpinBox:
|
||||
sb = QDoubleSpinBox()
|
||||
sb.setRange(minimum, maximum)
|
||||
sb.setDecimals(decimals)
|
||||
|
|
@ -107,9 +112,14 @@ class ElasticSectionForm(SectionFormBase):
|
|||
self._iy = _spin(8.33e-6, step=1e-7)
|
||||
self._g = _spin(80e9, step=1e9)
|
||||
self._j = _spin(1e-6, step=1e-7)
|
||||
for label, w in (("E:", self._e), ("A:", self._a),
|
||||
("Iz:", self._iz), ("Iy:", self._iy),
|
||||
("G:", self._g), ("J:", self._j)):
|
||||
for label, w in (
|
||||
("E:", self._e),
|
||||
("A:", self._a),
|
||||
("Iz:", self._iz),
|
||||
("Iy:", self._iy),
|
||||
("G:", self._g),
|
||||
("J:", self._j),
|
||||
):
|
||||
self._layout.addRow(label, w)
|
||||
self._layout.addRow(QLabel("<i>Iy, G, J required for 3D models.</i>"))
|
||||
|
||||
|
|
@ -196,10 +206,12 @@ class FiberSectionSummaryForm(SectionFormBase):
|
|||
self._summary.setWordWrap(True)
|
||||
self._summary.setStyleSheet("color: #555;")
|
||||
self._layout.addRow(self._summary)
|
||||
self._layout.addRow(QLabel(
|
||||
"<i>Edit this fiber section from the Section Library list — "
|
||||
"Add / Modify uses the visual Fiber Section Editor.</i>"
|
||||
))
|
||||
self._layout.addRow(
|
||||
QLabel(
|
||||
"<i>Edit this fiber section from the Section Library list — "
|
||||
"Add / Modify uses the visual Fiber Section Editor.</i>"
|
||||
)
|
||||
)
|
||||
self._cached: FiberSection | None = None
|
||||
|
||||
def _populate_specific(self, s: FiberSection) -> None:
|
||||
|
|
@ -240,9 +252,9 @@ class SectionAggregatorSummaryForm(SectionFormBase):
|
|||
|
||||
def _populate_specific(self, s: SectionAggregator) -> None:
|
||||
self._cached = s
|
||||
pairings = "<br>".join(
|
||||
f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings
|
||||
) or "(none)"
|
||||
pairings = (
|
||||
"<br>".join(f" mat #{p.material_id} on DOF {p.dof}" for p in s.pairings) or "(none)"
|
||||
)
|
||||
self._summary.setText(
|
||||
f"<b>{s.name or 'Section Aggregator'}</b><br>"
|
||||
f"Wraps section: {s.section_id}<br>"
|
||||
|
|
@ -268,10 +280,9 @@ def form_for(section: Any) -> SectionFormBase:
|
|||
# Graceful fallback — unknown section types display a minimal
|
||||
# placeholder instead of crashing the entire dialog.
|
||||
form = SectionFormBase()
|
||||
form._layout.addRow(QLabel(
|
||||
f"<i>No form registered for section type "
|
||||
f"<b>{section.type}</b> yet.</i>"
|
||||
))
|
||||
form._layout.addRow(
|
||||
QLabel(f"<i>No form registered for section type " f"<b>{section.type}</b> yet.</i>")
|
||||
)
|
||||
form._section_id = section.id
|
||||
form._name_edit.setText(getattr(section, "name", "") or "")
|
||||
form._name_edit.setEnabled(False)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ class SectionLibraryDialog(QDialog):
|
|||
"""
|
||||
from otko.core import FiberSection
|
||||
from otko.views.dialogs.section_editor import FiberSectionEditor
|
||||
|
||||
sec = self._selected_section()
|
||||
if not isinstance(sec, FiberSection) or self._vm.project is None:
|
||||
return
|
||||
|
|
@ -156,7 +157,12 @@ class SectionLibraryDialog(QDialog):
|
|||
summary_kinds = {"FiberSection", "SectionAggregator"}
|
||||
kinds = [k for k in FORM_REGISTRY if k not in summary_kinds]
|
||||
kind, ok = QInputDialog.getItem(
|
||||
self, "Add section", "Type:", kinds, current=0, editable=False,
|
||||
self,
|
||||
"Add section",
|
||||
"Type:",
|
||||
kinds,
|
||||
current=0,
|
||||
editable=False,
|
||||
)
|
||||
if not ok:
|
||||
return
|
||||
|
|
@ -176,6 +182,7 @@ class SectionLibraryDialog(QDialog):
|
|||
if self._vm.project is None:
|
||||
return
|
||||
from otko.views.dialogs.section_editor import FiberSectionEditor
|
||||
|
||||
mat_ids = [m.id for m in self._vm.project.materials]
|
||||
new_id = self._vm.project.next_section_id()
|
||||
dlg = FiberSectionEditor(mat_ids, parent=self)
|
||||
|
|
@ -196,9 +203,10 @@ class SectionLibraryDialog(QDialog):
|
|||
if section is None:
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete section",
|
||||
self,
|
||||
"Delete section",
|
||||
f"Delete section #{section.id} ({section.type})?\n"
|
||||
"Frame elements that reference it will be invalid until reassigned."
|
||||
"Frame elements that reference it will be invalid until reassigned.",
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -51,10 +51,12 @@ class UniformExcitationDialog(QDialog):
|
|||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.addWidget(QLabel(
|
||||
"<b>UniformExcitation</b> — apply a base ground motion "
|
||||
"to every free node in the chosen DOF direction."
|
||||
))
|
||||
root.addWidget(
|
||||
QLabel(
|
||||
"<b>UniformExcitation</b> — apply a base ground motion "
|
||||
"to every free node in the chosen DOF direction."
|
||||
)
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
self._name_edit = QLineEdit("GroundMotion")
|
||||
|
|
@ -84,16 +86,13 @@ class UniformExcitationDialog(QDialog):
|
|||
self._factor_spin.setDecimals(6)
|
||||
self._factor_spin.setSingleStep(0.1)
|
||||
self._factor_spin.setValue(1.0)
|
||||
self._factor_spin.setToolTip(
|
||||
"Extra scale applied on top of the TimeSeries' own factor."
|
||||
)
|
||||
self._factor_spin.setToolTip("Extra scale applied on top of the TimeSeries' own factor.")
|
||||
form.addRow("Factor:", self._factor_spin)
|
||||
|
||||
root.addLayout(form)
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok
|
||||
| QDialogButtonBox.StandardButton.Cancel,
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
from contextlib import suppress
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QDockWidget, QMessageBox, QPlainTextEdit, QTreeWidget, QTreeWidgetItem
|
||||
from PySide6.QtWidgets import QDockWidget, QMessageBox, QTreeWidget, QTreeWidgetItem
|
||||
|
||||
from otko.services.deformation import (
|
||||
linear_static_auto_scale,
|
||||
|
|
@ -34,6 +34,7 @@ from otko.services.results import (
|
|||
)
|
||||
from otko.views.canvas3d.model_renderer import RendererMode
|
||||
from otko.views.docks import (
|
||||
ConsoleDock,
|
||||
DeformedShapeView,
|
||||
ForceDiagramView,
|
||||
HysteresisView,
|
||||
|
|
@ -103,13 +104,9 @@ class DockManager:
|
|||
self.tabifyDockWidget(props_dock, tree_dock)
|
||||
tree_dock.raise_()
|
||||
|
||||
self._console = QPlainTextEdit()
|
||||
self._console.setReadOnly(True)
|
||||
self._console.setPlaceholderText("Logs and OpenSeesPy output will appear here.")
|
||||
console_dock = QDockWidget("Console", self)
|
||||
console_dock.setWidget(self._console)
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, console_dock)
|
||||
self._console_dock = console_dock
|
||||
self._console_dock = ConsoleDock(self)
|
||||
self._console = self._console_dock.view
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self._console_dock)
|
||||
|
||||
self._table = TableDock()
|
||||
table_dock = QDockWidget("Table", self)
|
||||
|
|
@ -122,7 +119,7 @@ class DockManager:
|
|||
results_dock.setWidget(self._results_panel)
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, results_dock)
|
||||
self._results_dock = results_dock
|
||||
self.tabifyDockWidget(console_dock, table_dock)
|
||||
self.tabifyDockWidget(self._console_dock, table_dock)
|
||||
self.tabifyDockWidget(table_dock, results_dock)
|
||||
table_dock.raise_()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Reusable dock widget contents."""
|
||||
|
||||
from otko.views.docks.console import ConsoleDock
|
||||
from otko.views.docks.deformed_shape import DeformedShapeView
|
||||
from otko.views.docks.force_diagram import ForceDiagramView
|
||||
from otko.views.docks.hysteresis import HysteresisView
|
||||
|
|
@ -12,8 +13,15 @@ from otko.views.docks.table_dock import TableDock
|
|||
from otko.views.docks.time_history import TimeHistoryView
|
||||
|
||||
__all__ = [
|
||||
"PropertyEditorDock", "ResultsPanel", "TableDock",
|
||||
"DeformedShapeView", "ModeShapeAnimator",
|
||||
"ForceDiagramView", "TimeHistoryView", "HysteresisView",
|
||||
"PushoverCurveView", "ResponseSpectrumView",
|
||||
"PropertyEditorDock",
|
||||
"ResultsPanel",
|
||||
"TableDock",
|
||||
"DeformedShapeView",
|
||||
"ModeShapeAnimator",
|
||||
"ForceDiagramView",
|
||||
"TimeHistoryView",
|
||||
"HysteresisView",
|
||||
"PushoverCurveView",
|
||||
"ResponseSpectrumView",
|
||||
"ConsoleDock",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ class DeformedShapeView(QWidget):
|
|||
scaleChanged = Signal(float)
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self, suggested_scale: float = 1.0,
|
||||
parent: QWidget | None = None) -> None:
|
||||
def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._suggested = max(suggested_scale, 1e-6)
|
||||
self._build_ui()
|
||||
|
|
@ -41,9 +40,9 @@ class DeformedShapeView(QWidget):
|
|||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(8, 8, 8, 8)
|
||||
layout.addWidget(QLabel("<b>Deformed Shape</b>"))
|
||||
layout.addWidget(QLabel(
|
||||
"<i>The slider scales displacement around the suggested factor.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel("<i>The slider scales displacement around the suggested factor.</i>")
|
||||
)
|
||||
|
||||
form = QFormLayout()
|
||||
|
||||
|
|
@ -53,8 +52,8 @@ class DeformedShapeView(QWidget):
|
|||
|
||||
# Multiplier ranging 0.1× — 10× the suggested scale.
|
||||
self._slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._slider.setRange(1, 1000) # represents 0.01 — 10.00
|
||||
self._slider.setValue(100) # 1.00 ×
|
||||
self._slider.setRange(1, 1000) # represents 0.01 — 10.00
|
||||
self._slider.setValue(100) # 1.00 ×
|
||||
self._slider.valueChanged.connect(self._on_slider)
|
||||
|
||||
self._spin = QDoubleSpinBox()
|
||||
|
|
|
|||
|
|
@ -31,12 +31,11 @@ from otko.services.element_forces import ForceComponent
|
|||
class ForceDiagramView(QWidget):
|
||||
"""Compact controls for live-updating an element-force diagram."""
|
||||
|
||||
componentChanged = Signal(object) # ForceComponent
|
||||
changed = Signal(object, float) # (ForceComponent, scale)
|
||||
componentChanged = Signal(object) # ForceComponent
|
||||
changed = Signal(object, float) # (ForceComponent, scale)
|
||||
closed = Signal()
|
||||
|
||||
def __init__(self, suggested_scale: float = 1.0,
|
||||
parent: QWidget | None = None) -> None:
|
||||
def __init__(self, suggested_scale: float = 1.0, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._scale_base = max(suggested_scale, 1e-12)
|
||||
self._pending_component_change = False
|
||||
|
|
@ -87,7 +86,7 @@ class ForceDiagramView(QWidget):
|
|||
|
||||
self._slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._slider.setRange(1, 1000)
|
||||
self._slider.setValue(500) # midpoint = suggested scale
|
||||
self._slider.setValue(500) # midpoint = suggested scale
|
||||
form.addRow("", self._slider)
|
||||
|
||||
root.addWidget(group)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,11 @@ from otko.core import Project
|
|||
# uniaxial material + area. The Convert command drops/adds fields to
|
||||
# bridge between them.
|
||||
_CONVERTIBLE_ELEMENT_TYPES = [
|
||||
"Truss", "CorotTruss",
|
||||
"ElasticBeamColumn", "ForceBeamColumn", "DispBeamColumn",
|
||||
"Truss",
|
||||
"CorotTruss",
|
||||
"ElasticBeamColumn",
|
||||
"ForceBeamColumn",
|
||||
"DispBeamColumn",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -53,7 +56,9 @@ class PropertyEditorDock(QScrollArea):
|
|||
self._layout.setContentsMargins(8, 8, 8, 8)
|
||||
self.setWidget(self._inner)
|
||||
self._project: Project | None = None
|
||||
self.on_apply_mass: Callable[[int, tuple[float, float, float, float, float, float]], None] | None = None
|
||||
self.on_apply_mass: (
|
||||
Callable[[int, tuple[float, float, float, float, float, float]], None] | None
|
||||
) = None
|
||||
# Element callbacks — all optional; wired by MainWindow.
|
||||
self.on_change_element_type: Callable[[int, str], None] | None = None
|
||||
self.on_change_element_material: Callable[[int, int], None] | None = None
|
||||
|
|
@ -121,9 +126,12 @@ class PropertyEditorDock(QScrollArea):
|
|||
self._layout.addWidget(QLabel(f"<h3>Node #{node.id}</h3>"))
|
||||
form = QFormLayout()
|
||||
form.addRow("Name:", QLabel(node.name or "—"))
|
||||
form.addRow("X, Y, Z:", QLabel(
|
||||
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
|
||||
))
|
||||
form.addRow(
|
||||
"X, Y, Z:",
|
||||
QLabel(
|
||||
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
|
||||
),
|
||||
)
|
||||
form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint)))
|
||||
self._layout.addLayout(form)
|
||||
|
||||
|
|
@ -179,6 +187,7 @@ class PropertyEditorDock(QScrollArea):
|
|||
def _on_type_changed(new_type: str, _eid: int = el.id) -> None:
|
||||
if new_type != el.type and self.on_change_element_type is not None:
|
||||
self.on_change_element_type(_eid, new_type)
|
||||
|
||||
type_cb.currentTextChanged.connect(_on_type_changed)
|
||||
form.addRow("Type:", type_cb)
|
||||
else:
|
||||
|
|
@ -188,16 +197,19 @@ class PropertyEditorDock(QScrollArea):
|
|||
form.addRow("Nodes:", QLabel(", ".join(str(n) for n in el.nodes)))
|
||||
|
||||
# ── Section picker for frame elements. ──
|
||||
if (hasattr(el, "section_id")
|
||||
and self._project is not None
|
||||
and self._project.sections
|
||||
and self.on_change_element_section is not None):
|
||||
if (
|
||||
hasattr(el, "section_id")
|
||||
and self._project is not None
|
||||
and self._project.sections
|
||||
and self.on_change_element_section is not None
|
||||
):
|
||||
sec_cb = QComboBox()
|
||||
for s in self._project.sections:
|
||||
sec_cb.addItem(
|
||||
f"#{s.id} {s.name or s.type}", s.id,
|
||||
f"#{s.id} {s.name or s.type}",
|
||||
s.id,
|
||||
)
|
||||
idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
|
||||
idx = sec_cb.findData(el.section_id) # type: ignore[attr-defined]
|
||||
if idx >= 0:
|
||||
sec_cb.setCurrentIndex(idx)
|
||||
|
||||
|
|
@ -205,20 +217,23 @@ class PropertyEditorDock(QScrollArea):
|
|||
sid = sec_cb.currentData()
|
||||
if sid is not None and self.on_change_element_section is not None:
|
||||
self.on_change_element_section(_eid, int(sid))
|
||||
|
||||
sec_cb.currentIndexChanged.connect(_on_section_changed)
|
||||
form.addRow("Section:", sec_cb)
|
||||
elif hasattr(el, "section_id"):
|
||||
form.addRow("Section id:", QLabel(str(el.section_id)))
|
||||
|
||||
# ── Material picker for truss / uniaxial-material elements. ──
|
||||
if (hasattr(el, "material_id")
|
||||
and self._project is not None
|
||||
and self._project.materials
|
||||
and self.on_change_element_material is not None):
|
||||
if (
|
||||
hasattr(el, "material_id")
|
||||
and self._project is not None
|
||||
and self._project.materials
|
||||
and self.on_change_element_material is not None
|
||||
):
|
||||
mat_cb = QComboBox()
|
||||
for m in self._project.materials:
|
||||
mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id)
|
||||
idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
|
||||
idx = mat_cb.findData(el.material_id) # type: ignore[attr-defined]
|
||||
if idx >= 0:
|
||||
mat_cb.setCurrentIndex(idx)
|
||||
|
||||
|
|
@ -226,6 +241,7 @@ class PropertyEditorDock(QScrollArea):
|
|||
mid = mat_cb.currentData()
|
||||
if mid is not None and self.on_change_element_material is not None:
|
||||
self.on_change_element_material(_eid, int(mid))
|
||||
|
||||
mat_cb.currentIndexChanged.connect(_on_material_changed)
|
||||
form.addRow("Material:", mat_cb)
|
||||
elif hasattr(el, "material_id"):
|
||||
|
|
@ -236,9 +252,12 @@ class PropertyEditorDock(QScrollArea):
|
|||
area_spin = QDoubleSpinBox()
|
||||
area_spin.setRange(1e-12, 1e6)
|
||||
area_spin.setDecimals(8)
|
||||
area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
|
||||
area_spin.setSingleStep(float(el.area) * 0.1 # type: ignore[attr-defined]
|
||||
if el.area else 0.001) # type: ignore[attr-defined]
|
||||
area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
|
||||
area_spin.setSingleStep(
|
||||
float(el.area) * 0.1 # type: ignore[attr-defined]
|
||||
if el.area
|
||||
else 0.001
|
||||
) # type: ignore[attr-defined]
|
||||
|
||||
# Commit on editingFinished so we don't dispatch a command on
|
||||
# every keystroke (which would spam the undo stack).
|
||||
|
|
@ -255,6 +274,7 @@ class PropertyEditorDock(QScrollArea):
|
|||
return
|
||||
if self.on_change_element_fields is not None:
|
||||
self.on_change_element_fields(_eid, {"area": new_val})
|
||||
|
||||
area_spin.editingFinished.connect(_on_area_edited)
|
||||
form.addRow("Area:", area_spin)
|
||||
elif hasattr(el, "area"):
|
||||
|
|
@ -268,13 +288,17 @@ class PropertyEditorDock(QScrollArea):
|
|||
def _show_multi(self, node_ids: frozenset[int], element_ids: frozenset[int]) -> None:
|
||||
self._layout.addWidget(QLabel("<h3>Multi-selection</h3>"))
|
||||
if node_ids:
|
||||
self._layout.addWidget(QLabel(
|
||||
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
|
||||
))
|
||||
self._layout.addWidget(
|
||||
QLabel(
|
||||
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
|
||||
)
|
||||
)
|
||||
if element_ids:
|
||||
self._layout.addWidget(QLabel(
|
||||
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
|
||||
))
|
||||
self._layout.addWidget(
|
||||
QLabel(
|
||||
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
|
||||
)
|
||||
)
|
||||
self._layout.addStretch(1)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -288,4 +312,6 @@ class PropertyEditorDock(QScrollArea):
|
|||
sorted_ids = sorted(ids)
|
||||
if len(sorted_ids) <= limit:
|
||||
return ", ".join(str(i) for i in sorted_ids)
|
||||
return ", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
|
||||
return (
|
||||
", ".join(str(i) for i in sorted_ids[:limit]) + f", … (+{len(sorted_ids) - limit} more)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ class ResponseSpectrumView(QWidget):
|
|||
self._build_ui()
|
||||
|
||||
def set_results(
|
||||
self, results: ResponseSpectrumResults | None,
|
||||
self,
|
||||
results: ResponseSpectrumResults | None,
|
||||
spectrum: ResponseSpectrum | None,
|
||||
) -> None:
|
||||
self._plot.clear()
|
||||
|
|
@ -59,9 +60,13 @@ class ResponseSpectrumView(QWidget):
|
|||
self._plot.plot(p_dense, a_dense, pen=pen, name="Sa(T)")
|
||||
# Original control points.
|
||||
self._plot.plot(
|
||||
list(spectrum.periods), list(spectrum.accelerations),
|
||||
pen=None, symbol="s", symbolSize=7,
|
||||
symbolBrush="#1f77b4", symbolPen=None,
|
||||
list(spectrum.periods),
|
||||
list(spectrum.accelerations),
|
||||
pen=None,
|
||||
symbol="s",
|
||||
symbolSize=7,
|
||||
symbolBrush="#1f77b4",
|
||||
symbolPen=None,
|
||||
name="Control pts",
|
||||
)
|
||||
|
||||
|
|
@ -77,14 +82,19 @@ class ResponseSpectrumView(QWidget):
|
|||
# Slight vertical jitter for duplicate periods
|
||||
y_offset = count * m.sa_at_period * 0.04
|
||||
self._plot.plot(
|
||||
[m.period], [m.sa_at_period + y_offset],
|
||||
pen=None, symbol="o", symbolSize=12,
|
||||
symbolBrush="#d62728", symbolPen=pg.mkPen("#ffffff", width=1),
|
||||
[m.period],
|
||||
[m.sa_at_period + y_offset],
|
||||
pen=None,
|
||||
symbol="o",
|
||||
symbolSize=12,
|
||||
symbolBrush="#d62728",
|
||||
symbolPen=pg.mkPen("#ffffff", width=1),
|
||||
name=f"Mode {m.mode_number}" if count == 0 else None,
|
||||
)
|
||||
# Small text label right next to the marker.
|
||||
txt = pg.TextItem(
|
||||
f" M{m.mode_number}", color="#d62728",
|
||||
f" M{m.mode_number}",
|
||||
color="#d62728",
|
||||
anchor=(0.0, 0.5),
|
||||
)
|
||||
txt.setPos(m.period, m.sa_at_period + y_offset)
|
||||
|
|
@ -133,9 +143,17 @@ class ResponseSpectrumView(QWidget):
|
|||
splitter.addWidget(self._plot)
|
||||
|
||||
self._table = QTableWidget(0, 7)
|
||||
self._table.setHorizontalHeaderLabels([
|
||||
"Mode", "T (s)", "f (Hz)", "Γ", "M_eff", "Mass %", "Sa(T)",
|
||||
])
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
[
|
||||
"Mode",
|
||||
"T (s)",
|
||||
"f (Hz)",
|
||||
"Γ",
|
||||
"M_eff",
|
||||
"Mass %",
|
||||
"Sa(T)",
|
||||
]
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(
|
||||
QHeaderView.ResizeMode.Stretch,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
|
|||
QWidget,
|
||||
)
|
||||
|
||||
from otko.services.combinations import EnvelopeResults
|
||||
from otko.services.results import (
|
||||
ModalResults,
|
||||
StaticResults,
|
||||
|
|
@ -59,7 +60,18 @@ class ResultsPanel(QWidget):
|
|||
"""
|
||||
self._ndm, self._ndf = ndm, ndf
|
||||
self._tabs.clear()
|
||||
if isinstance(results, StaticResults):
|
||||
if isinstance(results, EnvelopeResults):
|
||||
self._title.setText(
|
||||
f"<b>Combination (Envelope) — #{results.case_id} "
|
||||
f"'{results.case_name}'</b> ({results.n_steps} step(s))"
|
||||
)
|
||||
self._tabs.addTab(
|
||||
self._build_envelope_table(results, "disp"), "Displacements (max/min)"
|
||||
)
|
||||
self._tabs.addTab(
|
||||
self._build_envelope_table(results, "reaction"), "Reactions (max/min)"
|
||||
)
|
||||
elif isinstance(results, StaticResults):
|
||||
self._title.setText(
|
||||
f"<b>Static — case #{results.case_id} '{results.case_name}'</b> "
|
||||
f"({results.n_steps} step(s))"
|
||||
|
|
@ -67,9 +79,7 @@ class ResultsPanel(QWidget):
|
|||
self._tabs.addTab(self._build_static_disp_table(results), "Displacements")
|
||||
self._tabs.addTab(self._build_static_reaction_table(results), "Reactions")
|
||||
elif isinstance(results, ModalResults):
|
||||
self._title.setText(
|
||||
f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>"
|
||||
)
|
||||
self._title.setText(f"<b>Modal — case #{results.case_id} '{results.case_name}'</b>")
|
||||
self._tabs.addTab(self._build_modal_table(results), "Frequencies")
|
||||
elif isinstance(results, TransientResults):
|
||||
self._title.setText(
|
||||
|
|
@ -109,6 +119,28 @@ class ResultsPanel(QWidget):
|
|||
self._set_cell(table, i, j + 1, f"{val:.6g}")
|
||||
return self._wrap(table, "Final-step reactions")
|
||||
|
||||
def _build_envelope_table(self, r: EnvelopeResults, kind: str) -> QWidget:
|
||||
"""Max/min table for envelope ``kind`` (``"disp"`` or ``"reaction"``)."""
|
||||
max_map = r.node_disp_max if kind == "disp" else r.node_reaction_max
|
||||
min_map = r.node_disp_min if kind == "disp" else r.node_reaction_min
|
||||
rows = sorted(max_map.keys())
|
||||
if not rows:
|
||||
return self._empty_table_widget()
|
||||
ndf = max_map[rows[0]].shape[1]
|
||||
dofs = dof_labels(ndf, self._ndm, self._ndf, kind="disp" if kind == "disp" else "force")
|
||||
headers = ["Node", *[f"{d} max" for d in dofs], *[f"{d} min" for d in dofs]]
|
||||
table = self._make_table(headers, len(rows))
|
||||
for i, nid in enumerate(rows):
|
||||
self._set_cell(table, i, 0, str(nid))
|
||||
for j, val in enumerate(max_map[nid][-1]):
|
||||
self._set_cell(table, i, j + 1, f"{val:.6g}")
|
||||
for j, val in enumerate(min_map[nid][-1]):
|
||||
self._set_cell(table, i, j + 1 + ndf, f"{val:.6g}")
|
||||
caption = (
|
||||
"Final-step displacement envelope" if kind == "disp" else "Final-step reaction envelope"
|
||||
)
|
||||
return self._wrap(table, caption)
|
||||
|
||||
def _build_modal_table(self, r: ModalResults) -> QWidget:
|
||||
n = len(r.eigenvalues)
|
||||
headers = ["Mode", "Eigenvalue (rad²/s²)", "ω (rad/s)", "f (Hz)", "T (s)"]
|
||||
|
|
@ -124,15 +156,17 @@ class ResultsPanel(QWidget):
|
|||
def _transient_summary(self, r: TransientResults) -> QWidget:
|
||||
w = QWidget()
|
||||
layout = QVBoxLayout(w)
|
||||
layout.addWidget(QLabel(
|
||||
f"<b>Steps:</b> {r.n_steps}<br>"
|
||||
f"<b>dt:</b> {r.dt:g}<br>"
|
||||
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
|
||||
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
|
||||
))
|
||||
layout.addWidget(QLabel(
|
||||
"<i>Time-history plots and animation will appear here in Phase 7.</i>"
|
||||
))
|
||||
layout.addWidget(
|
||||
QLabel(
|
||||
f"<b>Steps:</b> {r.n_steps}<br>"
|
||||
f"<b>dt:</b> {r.dt:g}<br>"
|
||||
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
|
||||
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
|
||||
)
|
||||
)
|
||||
layout.addWidget(
|
||||
QLabel("<i>Time-history plots and animation will appear here in Phase 7.</i>")
|
||||
)
|
||||
layout.addStretch(1)
|
||||
return w
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from PySide6.QtWidgets import (
|
|||
|
||||
from otko.commands import (
|
||||
AddAnalysisCasesCommand,
|
||||
AddCombinationsCommand,
|
||||
AddElementLoadsCommand,
|
||||
AddElementsCommand,
|
||||
AddEqualDOFConstraintCommand,
|
||||
|
|
@ -47,6 +48,7 @@ from otko.commands import (
|
|||
AssignMaterialCommand,
|
||||
AssignSectionCommand,
|
||||
DeleteAnalysisCasesCommand,
|
||||
DeleteCombinationsCommand,
|
||||
DeleteElementLoadsCommand,
|
||||
DeleteElementsCommand,
|
||||
DeleteEqualDOFConstraintCommand,
|
||||
|
|
@ -59,6 +61,7 @@ from otko.commands import (
|
|||
SetMassCommand,
|
||||
SetRestraintCommand,
|
||||
UpdateAnalysisCaseCommand,
|
||||
UpdateCombinationCommand,
|
||||
UpdateElementFieldsCommand,
|
||||
UpdateElementLoadCommand,
|
||||
UpdateEqualDOFConstraintCommand,
|
||||
|
|
@ -75,6 +78,8 @@ from otko.core import (
|
|||
ElasticUniaxial,
|
||||
EqualDOFConstraint,
|
||||
LinearTimeSeries,
|
||||
LoadCombination,
|
||||
LoadCombinationItem,
|
||||
NodalLoad,
|
||||
Node,
|
||||
PlainLoadPattern,
|
||||
|
|
@ -169,6 +174,7 @@ _ANALYSIS_ATTRS = [
|
|||
"tolerance",
|
||||
"max_iter",
|
||||
]
|
||||
_COMBO_HEADERS = ["id", "name", "kind", "cases", "factors"]
|
||||
|
||||
_TAB_ORDER = (
|
||||
"Nodes",
|
||||
|
|
@ -181,6 +187,7 @@ _TAB_ORDER = (
|
|||
"Element Loads",
|
||||
"Constraints",
|
||||
"Analyses",
|
||||
"Combinations",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -270,6 +277,7 @@ class TableDock(QWidget):
|
|||
"Element Loads": self._load_element_loads,
|
||||
"Constraints": self._load_constraints,
|
||||
"Analyses": self._load_analyses,
|
||||
"Combinations": self._load_combinations,
|
||||
}
|
||||
for name in _TAB_ORDER:
|
||||
page = QWidget()
|
||||
|
|
@ -417,6 +425,7 @@ class TableDock(QWidget):
|
|||
"Element Loads": self._commit_element_load,
|
||||
"Constraints": self._commit_constraint,
|
||||
"Analyses": self._commit_analysis,
|
||||
"Combinations": self._commit_combination,
|
||||
}[tab]
|
||||
handler(self._tables[tab], item)
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
|
|
@ -1095,6 +1104,49 @@ class TableDock(QWidget):
|
|||
updated = self._dry_run(case, {attr: value})
|
||||
self._dispatch(UpdateAnalysisCaseCommand(vm, updated))
|
||||
|
||||
# ── Combinations ───────────────────────────────────────────────
|
||||
def _load_combinations(self, table: QTableWidget) -> None:
|
||||
combos = list(self._project.combinations) if self._project is not None else []
|
||||
table.setColumnCount(len(_COMBO_HEADERS))
|
||||
table.setHorizontalHeaderLabels(_COMBO_HEADERS)
|
||||
table.setRowCount(len(combos))
|
||||
for r, combo in enumerate(combos):
|
||||
self._put(table, r, 0, str(combo.id), editable=False, data=combo.id)
|
||||
self._put(table, r, 1, combo.name, data=combo.id)
|
||||
self._put(table, r, 2, combo.kind, data=combo.id)
|
||||
self._put(
|
||||
table,
|
||||
r,
|
||||
3,
|
||||
", ".join(str(i.case_id) for i in combo.items),
|
||||
editable=False,
|
||||
data=combo.id,
|
||||
)
|
||||
self._put(
|
||||
table,
|
||||
r,
|
||||
4,
|
||||
", ".join(f"{i.factor:g}" for i in combo.items),
|
||||
editable=False,
|
||||
data=combo.id,
|
||||
)
|
||||
|
||||
def _commit_combination(self, table: QTableWidget, item: QTableWidgetItem) -> None:
|
||||
vm, project = self._bound()
|
||||
row, col = item.row(), item.column()
|
||||
cid = self._row_key(table, row)
|
||||
combo = next(c for c in project.combinations if c.id == cid)
|
||||
if col == 1:
|
||||
updated = self._dry_run(combo, {"name": item.text()})
|
||||
elif col == 2:
|
||||
raw = item.text().strip()
|
||||
if raw not in ("Linear", "Envelope"):
|
||||
raise ValueError("kind: expected 'Linear' or 'Envelope'.")
|
||||
updated = self._dry_run(combo, {"kind": raw})
|
||||
else:
|
||||
return
|
||||
self._dispatch(UpdateCombinationCommand(vm, updated))
|
||||
|
||||
# ── Add / Delete rows ──────────────────────────────────────────
|
||||
def _require_project(self, tab: str) -> Any | None:
|
||||
if self._vm is None or self._project is None:
|
||||
|
|
@ -1192,6 +1244,16 @@ class TableDock(QWidget):
|
|||
pattern_ids=[project.load_patterns[0].id],
|
||||
)
|
||||
self._dispatch(AddAnalysisCasesCommand(vm, [case]))
|
||||
elif tab == "Combinations":
|
||||
if not project.analyses:
|
||||
self._say("Combinations: define an analysis case first.")
|
||||
return
|
||||
combo = LoadCombination(
|
||||
id=project.next_combination_id(),
|
||||
name="New",
|
||||
items=[LoadCombinationItem(case_id=project.analyses[0].id)],
|
||||
)
|
||||
self._dispatch(AddCombinationsCommand(vm, [combo]))
|
||||
except Exception as exc:
|
||||
label = tab[:-1] if tab.endswith("s") else tab
|
||||
self._say(f"Add {label} failed: {exc}")
|
||||
|
|
@ -1264,6 +1326,8 @@ class TableDock(QWidget):
|
|||
self._dispatch(DeleteLoadPatternCommand(vm, ids))
|
||||
elif tab == "Analyses":
|
||||
self._dispatch(DeleteAnalysisCasesCommand(vm, ids))
|
||||
elif tab == "Combinations":
|
||||
self._dispatch(DeleteCombinationsCommand(vm, ids))
|
||||
except Exception as exc:
|
||||
self._say(f"Delete {tab} failed: {exc}")
|
||||
self.refresh()
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from PySide6.QtCore import QSettings
|
||||
from PySide6.QtGui import QCloseEvent, QIcon
|
||||
from PySide6.QtWidgets import QMainWindow, QMessageBox
|
||||
|
||||
from otko.viewmodels import AnalysisRunner, ProjectViewModel
|
||||
from otko.views.action_handlers import ActionHandlers
|
||||
|
|
@ -74,6 +75,7 @@ class MainWindow(
|
|||
self._build_status_bar()
|
||||
self._wire()
|
||||
self._refresh_action_enablement()
|
||||
self.restore_layout()
|
||||
|
||||
# ── construction ─────────────────────────────────────────────────
|
||||
def _build_central_canvas(self) -> None:
|
||||
|
|
@ -82,6 +84,54 @@ class MainWindow(
|
|||
# Diagram overlay paints onto the same plotter as the model.
|
||||
self._diagram_renderer = DiagramRenderer(self._canvas)
|
||||
|
||||
# ── layout persistence ───────────────────────────────────────────
|
||||
def save_layout(self) -> None:
|
||||
"""Persist window geometry and dock/toolbar state for next launch."""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
settings.setValue("geometry", self.saveGeometry())
|
||||
settings.setValue("windowState", self.saveState())
|
||||
|
||||
def restore_layout(self) -> None:
|
||||
"""Restore a previously saved geometry/dock layout, if any.
|
||||
|
||||
On first run no values are stored, so the defaults from ``__init__``
|
||||
(``resize`` + dock construction) stay in effect.
|
||||
"""
|
||||
settings = QSettings("OTKO", "OTKO")
|
||||
geometry = settings.value("geometry")
|
||||
if geometry is not None:
|
||||
self.restoreGeometry(geometry)
|
||||
state = settings.value("windowState")
|
||||
if state is not None:
|
||||
self.restoreState(state)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override)
|
||||
"""Prompt to save unsaved work, then persist the window layout.
|
||||
|
||||
The prompt is shown only for a visible window: Qt also closes hidden
|
||||
widgets during teardown (for example under ``pytest-qt``), where a
|
||||
modal dialog would block with no user to answer it.
|
||||
"""
|
||||
if self._vm.is_dirty and self.isVisible():
|
||||
buttons = QMessageBox.StandardButton
|
||||
choice = QMessageBox.warning(
|
||||
self,
|
||||
"Unsaved changes",
|
||||
"Save changes to the current project before closing?",
|
||||
buttons.Save | buttons.Discard | buttons.Cancel,
|
||||
buttons.Save,
|
||||
)
|
||||
if choice == buttons.Cancel:
|
||||
event.ignore()
|
||||
return
|
||||
if choice == buttons.Save:
|
||||
self._on_save()
|
||||
if self._vm.is_dirty:
|
||||
event.ignore()
|
||||
return
|
||||
self.save_layout()
|
||||
event.accept()
|
||||
|
||||
# ── wiring ────────────────────────────────────────────────────────
|
||||
def _wire(self) -> None:
|
||||
# File
|
||||
|
|
@ -141,6 +191,7 @@ class MainWindow(
|
|||
|
||||
# Analyze
|
||||
self._act_case_manager.triggered.connect(self._on_case_manager)
|
||||
self._act_combo_manager.triggered.connect(self._on_combo_manager)
|
||||
self._act_run.triggered.connect(self._on_run_analysis)
|
||||
|
||||
# Display
|
||||
|
|
@ -156,7 +207,7 @@ class MainWindow(
|
|||
self._act_back_to_model.triggered.connect(self._on_back_to_model)
|
||||
|
||||
# AnalysisRunner: stream log to console + show results in panel
|
||||
self._runner.log.connect(self._console.appendPlainText)
|
||||
self._runner.log.connect(self._console_dock.log)
|
||||
self._runner.finished.connect(self._on_analysis_finished)
|
||||
self._runner.failed.connect(self._on_analysis_failed)
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ class MenuBuilder:
|
|||
|
||||
# Analyze
|
||||
self._act_case_manager = QAction("&Cases…", self, shortcut="Ctrl+Shift+A")
|
||||
self._act_combo_manager = QAction("&Load Combinations…", self)
|
||||
self._act_run = QAction("&Run…", self, shortcut="F5")
|
||||
|
||||
# View (Iso/Top/Front/Right are an exclusive checkable group so the
|
||||
|
|
@ -285,6 +286,11 @@ class MenuBuilder:
|
|||
"Analysis Cases (Ctrl+Shift+A)",
|
||||
"Manage analysis cases.",
|
||||
),
|
||||
"_act_combo_manager": (
|
||||
"cases",
|
||||
"Load Combinations",
|
||||
"Manage named case-result load combinations.",
|
||||
),
|
||||
"_act_show_deformed": (
|
||||
"show-deformed",
|
||||
"Show Deformed Shape",
|
||||
|
|
@ -420,6 +426,7 @@ class MenuBuilder:
|
|||
|
||||
m_analyze = mb.addMenu("&Analyze")
|
||||
m_analyze.addAction(self._act_case_manager)
|
||||
m_analyze.addAction(self._act_combo_manager)
|
||||
m_analyze.addSeparator()
|
||||
m_analyze.addAction(self._act_run)
|
||||
|
||||
|
|
@ -482,7 +489,7 @@ class MenuBuilder:
|
|||
tb.setMovable(True)
|
||||
tb.setIconSize(QSize(24, 24))
|
||||
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
||||
# Top edge; Tools stays in the left area independently (built next).
|
||||
# Top edge; Tools shares the top area (built next).
|
||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
||||
self._view_toolbar = tb
|
||||
tb.addAction(self._act_zoom_extents)
|
||||
|
|
@ -528,9 +535,8 @@ class MenuBuilder:
|
|||
tb.setMovable(True)
|
||||
tb.setIconSize(QSize(24, 24))
|
||||
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
||||
# View lives on the top edge, so Tools stays left independently
|
||||
# in its own left-area column.
|
||||
self.addToolBar(Qt.ToolBarArea.LeftToolBarArea, tb)
|
||||
# Top edge alongside the View toolbar (built just before).
|
||||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, tb)
|
||||
self._tools_toolbar = tb
|
||||
tb.addAction(self._act_tool_select)
|
||||
tb.addAction(self._act_tool_draw_node)
|
||||
|
|
|
|||
|
|
@ -496,5 +496,5 @@ class RenderControls:
|
|||
self._act_clear_display.setEnabled(has_project)
|
||||
|
||||
def _log(self, message: str) -> None:
|
||||
self._console.appendPlainText(message)
|
||||
self._console_dock.log(message)
|
||||
self.statusBar().showMessage(message, 5000)
|
||||
|
|
|
|||
|
|
@ -5,5 +5,11 @@ from otko.views.tools.draw_frame import DrawFrameTool
|
|||
from otko.views.tools.draw_node import DrawNodeTool
|
||||
from otko.views.tools.draw_truss import DrawTrussTool
|
||||
|
||||
__all__ = ["CanvasTool", "SelectTool", "ToolController",
|
||||
"DrawFrameTool", "DrawNodeTool", "DrawTrussTool"]
|
||||
__all__ = [
|
||||
"CanvasTool",
|
||||
"SelectTool",
|
||||
"ToolController",
|
||||
"DrawFrameTool",
|
||||
"DrawNodeTool",
|
||||
"DrawTrussTool",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -84,10 +84,11 @@ class SelectTool(CanvasTool):
|
|||
class ToolController(QObject):
|
||||
"""Owns the active tool and routes canvas pick signals to it."""
|
||||
|
||||
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
|
||||
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
|
||||
|
||||
def __init__(self, canvas: "ModelCanvas", vm: "ProjectViewModel",
|
||||
parent: QObject | None = None) -> None:
|
||||
def __init__(
|
||||
self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._canvas = canvas
|
||||
self._vm = vm
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ class DrawFrameTool(LinearElementDrawTool):
|
|||
section_id = self._ensure_default_section()
|
||||
element_id = project.next_element_id()
|
||||
elem = ElasticBeamColumn(
|
||||
id=element_id, nodes=(n1, n2), section_id=section_id,
|
||||
id=element_id,
|
||||
nodes=(n1, n2),
|
||||
section_id=section_id,
|
||||
)
|
||||
self._vm.apply_command(AddElementsCommand(self._vm, [elem]))
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -27,14 +27,20 @@ if TYPE_CHECKING:
|
|||
|
||||
|
||||
def _snap_to_grid(
|
||||
x: float, y: float, z: float,
|
||||
x_lines: list[float], y_lines: list[float], z_lines: list[float],
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
x_lines: list[float],
|
||||
y_lines: list[float],
|
||||
z_lines: list[float],
|
||||
) -> tuple[float, float, float]:
|
||||
"""Snap each coordinate to its closest grid line (identity on empty axes)."""
|
||||
|
||||
def nearest(v: float, lines: list[float]) -> float:
|
||||
if not lines:
|
||||
return v
|
||||
return min(lines, key=lambda c: abs(c - v))
|
||||
|
||||
return nearest(x, x_lines), nearest(y, y_lines), nearest(z, z_lines)
|
||||
|
||||
|
||||
|
|
@ -70,16 +76,17 @@ def _snap_with_distance(
|
|||
if not (grid.x_lines or grid.y_lines or grid.z_lines):
|
||||
continue
|
||||
lx, ly, lz = cs.coord.world_to_local(world)
|
||||
sl = _snap_to_grid(lx, ly, lz,
|
||||
grid.x_lines, grid.y_lines, grid.z_lines)
|
||||
sl = _snap_to_grid(lx, ly, lz, grid.x_lines, grid.y_lines, grid.z_lines)
|
||||
candidate = cs.coord.local_to_world(sl)
|
||||
d2 = ((candidate[0] - world[0]) ** 2
|
||||
+ (candidate[1] - world[1]) ** 2
|
||||
+ (candidate[2] - world[2]) ** 2)
|
||||
d2 = (
|
||||
(candidate[0] - world[0]) ** 2
|
||||
+ (candidate[1] - world[1]) ** 2
|
||||
+ (candidate[2] - world[2]) ** 2
|
||||
)
|
||||
if d2 < best_d2:
|
||||
best = candidate
|
||||
best_d2 = d2
|
||||
return best, (best_d2 ** 0.5) if best is not None else float("inf")
|
||||
return best, (best_d2**0.5) if best is not None else float("inf")
|
||||
|
||||
|
||||
def _min_grid_spacing(systems: list[CoordinateGridSystem]) -> float | None:
|
||||
|
|
@ -124,13 +131,13 @@ class DrawNodeTool(CanvasTool):
|
|||
super().deactivate()
|
||||
|
||||
def prompt(self) -> str:
|
||||
return ("Draw Node: click a grid intersection "
|
||||
"to place a node. Switch to Select tool to finish.")
|
||||
return (
|
||||
"Draw Node: click a grid intersection "
|
||||
"to place a node. Switch to Select tool to finish."
|
||||
)
|
||||
|
||||
def on_node_picked(self, node_id: int) -> None:
|
||||
self.statusChanged.emit(
|
||||
f"Draw Node: node {node_id} already exists at that spot."
|
||||
)
|
||||
self.statusChanged.emit(f"Draw Node: node {node_id} already exists at that spot.")
|
||||
|
||||
def on_empty_clicked(self, x: float, y: float, z: float) -> None:
|
||||
"""Called only when the canvas has already confirmed a grid snap.
|
||||
|
|
@ -153,9 +160,5 @@ class DrawNodeTool(CanvasTool):
|
|||
|
||||
nid = project.next_node_id()
|
||||
node = Node(id=nid, coords=(x, y, z))
|
||||
self._vm.apply_command(
|
||||
AddNodesCommand(self._vm, [node], text=f"Add node {nid}")
|
||||
)
|
||||
self.statusChanged.emit(
|
||||
f"Draw Node: added node {nid} at ({x:g}, {y:g}, {z:g})."
|
||||
)
|
||||
self._vm.apply_command(AddNodesCommand(self._vm, [node], text=f"Add node {nid}"))
|
||||
self.statusChanged.emit(f"Draw Node: added node {nid} at ({x:g}, {y:g}, {z:g}).")
|
||||
|
|
|
|||
|
|
@ -1,22 +1,30 @@
|
|||
"""Shared pytest fixtures.
|
||||
"""Shared pytest configuration.
|
||||
|
||||
`pytest-qt` automatically provides a `qtbot` fixture and a QApplication
|
||||
instance. We add convenience fixtures here as the suite grows.
|
||||
The GUI suite is headless by default: unless the caller already chose a
|
||||
platform, ``QT_QPA_PLATFORM=offscreen`` is set here, before pytest-qt
|
||||
builds its session ``QApplication``. An explicit caller value is respected.
|
||||
|
||||
OpenseesPy is a process-global singleton, so a lazy autouse guard wipes the
|
||||
domain after every test. The import is inside the teardown so that
|
||||
``tests/unit`` never imports the solver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_opensees() -> None:
|
||||
"""Reset OpenSees domain between tests if openseespy is importable.
|
||||
|
||||
Imported lazily so that pure-core tests don't pull in the C++ runtime.
|
||||
"""
|
||||
def _wipe_opensees_domain() -> Iterator[None]:
|
||||
"""Wipe the OpenseesPy domain after a test if the solver was imported."""
|
||||
yield
|
||||
try:
|
||||
import openseespy.opensees as ops
|
||||
except ImportError:
|
||||
from openseespy.opensees import wipe
|
||||
except Exception: # pragma: no cover - solver not installed / import side effects
|
||||
return
|
||||
ops.wipe()
|
||||
wipe()
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ def _vm_with_pattern() -> ProjectViewModel:
|
|||
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
|
||||
vm.project.time_series.append(LinearTimeSeries(id=1))
|
||||
vm.project.load_patterns.append(
|
||||
PlainLoadPattern(id=1, time_series_id=1,
|
||||
nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))])
|
||||
PlainLoadPattern(
|
||||
id=1, time_series_id=1, nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))]
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
|
@ -57,9 +58,14 @@ def test_add_modal_case_no_pattern_needed(qtbot) -> None: # type: ignore[no-unt
|
|||
def test_add_emits_modelMutated(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
with qtbot.waitSignal(vm.modelMutated, timeout=500):
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddAnalysisCasesCommand(
|
||||
vm,
|
||||
[
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
|
|
@ -74,9 +80,14 @@ def test_add_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
@pytest.mark.gui
|
||||
def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [
|
||||
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddAnalysisCasesCommand(
|
||||
vm,
|
||||
[
|
||||
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
new = StaticCase(id=1, name="A", pattern_ids=[1], n_steps=10)
|
||||
vm.apply_command(UpdateAnalysisCaseCommand(vm, new))
|
||||
assert vm.project.analyses[0].n_steps == 10
|
||||
|
|
@ -87,9 +98,14 @@ def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untype
|
|||
@pytest.mark.gui
|
||||
def test_update_changes_case_type(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddAnalysisCasesCommand(
|
||||
vm,
|
||||
[
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
],
|
||||
)
|
||||
)
|
||||
swapped = ModalCase(id=1, name="Mode swap", n_modes=5)
|
||||
vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped))
|
||||
assert isinstance(vm.project.analyses[0], ModalCase)
|
||||
|
|
@ -109,10 +125,15 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
|
|||
@pytest.mark.gui
|
||||
def test_delete_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_with_pattern()
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
ModalCase(id=2),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddAnalysisCasesCommand(
|
||||
vm,
|
||||
[
|
||||
StaticCase(id=1, pattern_ids=[1]),
|
||||
ModalCase(id=2),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(DeleteAnalysisCasesCommand(vm, {1}))
|
||||
assert {c.id for c in vm.project.analyses} == {2}
|
||||
vm.undo_stack.undo()
|
||||
|
|
|
|||
|
|
@ -35,21 +35,42 @@ def cantilever_vm() -> ProjectViewModel:
|
|||
L = 5.0
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project(ndm=2, ndf=3)
|
||||
vm.apply_command(AddNodesCommand(vm, [
|
||||
Node(id=1, coords=(0.0, 0.0, 0.0),
|
||||
restraint=(True, True, False, False, False, True)),
|
||||
Node(id=2, coords=(L, 0.0, 0.0)),
|
||||
]))
|
||||
vm.apply_command(AddSectionsCommand(vm, [
|
||||
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
|
||||
]))
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddNodesCommand(
|
||||
vm,
|
||||
[
|
||||
Node(
|
||||
id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)
|
||||
),
|
||||
Node(id=2, coords=(L, 0.0, 0.0)),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
AddSectionsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(AddNodalLoadsCommand(vm, {2}, (0.0, -1000.0, 0.0, 0.0, 0.0, 0.0)))
|
||||
vm.apply_command(AddAnalysisCasesCommand(vm, [
|
||||
StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddAnalysisCasesCommand(
|
||||
vm,
|
||||
[
|
||||
StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -48,10 +48,11 @@ def test_dialog_lists_existing_patterns(qtbot) -> None: # type: ignore[no-untyp
|
|||
@pytest.mark.gui
|
||||
def test_dialog_enables_name_field_for_new_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
dlg = AssignLoadDialog(
|
||||
n_selected=1, existing_patterns=[(1, "Gravity")],
|
||||
n_selected=1,
|
||||
existing_patterns=[(1, "Gravity")],
|
||||
)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._pattern_cb.setCurrentIndex(1) # "<New pattern…>"
|
||||
dlg._pattern_cb.setCurrentIndex(1) # "<New pattern…>"
|
||||
assert dlg.selected_pattern_id() is None
|
||||
assert dlg._new_name_edit.isEnabled()
|
||||
dlg._new_name_edit.setText("RefMoment")
|
||||
|
|
@ -68,14 +69,23 @@ def test_command_creates_named_pattern(qtbot) -> None: # type: ignore[no-untype
|
|||
# Pre-existing 'Gravity' pattern — must NOT be reused when the user
|
||||
# asks for a new one named 'RefMoment'.
|
||||
vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
|
||||
vm.project.load_patterns.append(PlainLoadPattern(
|
||||
id=1, name="Gravity", time_series_id=1,
|
||||
))
|
||||
vm.project.load_patterns.append(
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
name="Gravity",
|
||||
time_series_id=1,
|
||||
)
|
||||
)
|
||||
|
||||
vm.apply_command(AddNodalLoadsCommand(
|
||||
vm, {1}, (0, 0, 0, 0, 0, 1.0),
|
||||
pattern_id=None, new_pattern_name="RefMoment",
|
||||
))
|
||||
vm.apply_command(
|
||||
AddNodalLoadsCommand(
|
||||
vm,
|
||||
{1},
|
||||
(0, 0, 0, 0, 0, 1.0),
|
||||
pattern_id=None,
|
||||
new_pattern_name="RefMoment",
|
||||
)
|
||||
)
|
||||
# Two patterns now: Gravity (id=1) and RefMoment (id=2).
|
||||
names = [p.name for p in vm.project.load_patterns]
|
||||
assert "Gravity" in names and "RefMoment" in names
|
||||
|
|
@ -90,13 +100,20 @@ def test_command_creates_constant_timeseries_when_requested(qtbot) -> None: # t
|
|||
the new pattern — required for axial preloads in moment-curvature
|
||||
runs where pseudoTime must NOT scale the axial force."""
|
||||
from otko.core import ConstantTimeSeries
|
||||
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project(ndm=2, ndf=3)
|
||||
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
|
||||
vm.apply_command(AddNodalLoadsCommand(
|
||||
vm, {1}, (-180, 0, 0, 0, 0, 0),
|
||||
pattern_id=None, new_pattern_name="AxialP", new_ts_type="Constant",
|
||||
))
|
||||
vm.apply_command(
|
||||
AddNodalLoadsCommand(
|
||||
vm,
|
||||
{1},
|
||||
(-180, 0, 0, 0, 0, 0),
|
||||
pattern_id=None,
|
||||
new_pattern_name="AxialP",
|
||||
new_ts_type="Constant",
|
||||
)
|
||||
)
|
||||
# Pattern + TimeSeries both created with the "AxialP" name.
|
||||
assert vm.project.load_patterns[0].name == "AxialP"
|
||||
ts = vm.project.time_series[0]
|
||||
|
|
@ -111,11 +128,20 @@ def test_command_reuses_existing_pattern_by_id(qtbot) -> None: # type: ignore[n
|
|||
vm.new_project(ndm=2, ndf=3)
|
||||
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
|
||||
vm.project.time_series.append(LinearTimeSeries(id=1, name="Existing"))
|
||||
vm.project.load_patterns.append(PlainLoadPattern(
|
||||
id=1, name="Existing", time_series_id=1,
|
||||
))
|
||||
vm.apply_command(AddNodalLoadsCommand(
|
||||
vm, {1}, (0, 0, 0, 0, 0, 5.0), pattern_id=1,
|
||||
))
|
||||
vm.project.load_patterns.append(
|
||||
PlainLoadPattern(
|
||||
id=1,
|
||||
name="Existing",
|
||||
time_series_id=1,
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
AddNodalLoadsCommand(
|
||||
vm,
|
||||
{1},
|
||||
(0, 0, 0, 0, 0, 5.0),
|
||||
pattern_id=1,
|
||||
)
|
||||
)
|
||||
assert len(vm.project.load_patterns) == 1
|
||||
assert len(vm.project.load_patterns[0].nodal_loads) == 1
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-
|
|||
dlg._mx.setValue(5000.0)
|
||||
dlg._my.setValue(5000.0)
|
||||
dlg._mz.setValue(100.0)
|
||||
dlg._mxx.setValue(0.0); dlg._myy.setValue(0.0); dlg._mzz.setValue(0.0)
|
||||
dlg._mxx.setValue(0.0)
|
||||
dlg._myy.setValue(0.0)
|
||||
dlg._mzz.setValue(0.0)
|
||||
assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,15 +18,24 @@ from otko.views.dialogs.assign_zls import ( # noqa: E402
|
|||
|
||||
def _project_with_section() -> Project:
|
||||
return Project(
|
||||
ndm=2, ndf=3,
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(0, 0, 0)),
|
||||
],
|
||||
sections=[ElasticSection(
|
||||
id=3, name="MK", E=30000, A=200, Iz=6667, Iy=6667,
|
||||
G=12000, J=100,
|
||||
)],
|
||||
sections=[
|
||||
ElasticSection(
|
||||
id=3,
|
||||
name="MK",
|
||||
E=30000,
|
||||
A=200,
|
||||
Iz=6667,
|
||||
Iy=6667,
|
||||
G=12000,
|
||||
J=100,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -43,7 +52,8 @@ def test_dialog_lists_project_sections(qtbot) -> None: # type: ignore[no-untype
|
|||
@pytest.mark.gui
|
||||
def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
p = Project(
|
||||
ndm=2, ndf=3,
|
||||
ndm=2,
|
||||
ndf=3,
|
||||
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0))],
|
||||
)
|
||||
dlg = AssignZeroLengthSectionDialog(p, (1, 2))
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ from otko.core import ( # noqa: E402
|
|||
|
||||
# ────────────────────── logic helpers (no VTK) ──────────────────────
|
||||
def _nearest_snap(
|
||||
cx: float, cy: float,
|
||||
cx: float,
|
||||
cy: float,
|
||||
world_pts: np.ndarray,
|
||||
screen_pts: np.ndarray,
|
||||
tol_px: float,
|
||||
|
|
@ -33,7 +34,7 @@ def _nearest_snap(
|
|||
return None
|
||||
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
|
||||
idx = int(np.argmin(d2))
|
||||
if d2[idx] <= tol_px ** 2:
|
||||
if d2[idx] <= tol_px**2:
|
||||
return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
|
||||
return None
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
|
|||
"""ModelCanvas._grid_intersections_world combines every visible system's
|
||||
intersections (transformed by that system's origin/rotation)."""
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
|
||||
|
|
@ -101,15 +103,17 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
|
|||
|
||||
def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
canvas.show_project(Project()) # default: Global system with no grid lines
|
||||
canvas.show_project(Project()) # default: Global system with no grid lines
|
||||
assert canvas._grid_intersections_world() is None
|
||||
|
||||
|
||||
def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""A system with ``hide_all=True`` must not contribute snap targets."""
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
|
||||
|
|
@ -134,20 +138,23 @@ def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-un
|
|||
def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""set_hover_snap(pt) creates an actor; passing None removes it."""
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
canvas.show_project(Project(
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
canvas.show_project(
|
||||
Project(
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
],
|
||||
)
|
||||
)
|
||||
r = canvas._renderer
|
||||
assert r._hover_actor is None
|
||||
r.set_hover_snap((3.0, 0.0, 0.0))
|
||||
|
|
@ -158,20 +165,23 @@ def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untype
|
|||
|
||||
def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
canvas.show_project(Project(
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
canvas.show_project(
|
||||
Project(
|
||||
coord_systems=[
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
grid=GridSystem(
|
||||
x_grid_lines=make_grid_lines("X", [0.0]),
|
||||
y_grid_lines=make_grid_lines("Y", [0.0]),
|
||||
z_grid_lines=make_grid_lines("Z", [0.0]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
))
|
||||
],
|
||||
)
|
||||
)
|
||||
canvas.set_snap_preview_enabled(True)
|
||||
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
|
||||
assert canvas._renderer._hover_actor is not None
|
||||
|
|
@ -183,7 +193,7 @@ def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-unty
|
|||
def _pt_to_segment_d2(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
||||
"""Vectorised point-to-segment squared distance, used to verify logic."""
|
||||
ab = b - a
|
||||
ab_sq = (ab ** 2).sum(axis=1)
|
||||
ab_sq = (ab**2).sum(axis=1)
|
||||
ab_sq = np.where(ab_sq == 0, 1.0, ab_sq)
|
||||
pa = p - a
|
||||
t = (pa * ab).sum(axis=1) / ab_sq
|
||||
|
|
@ -204,21 +214,21 @@ def test_point_to_segment_midpoint_hit() -> None:
|
|||
|
||||
def test_point_to_segment_endpoint_hit() -> None:
|
||||
"""A click near an endpoint must also hit — the bug fix this test guards."""
|
||||
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
|
||||
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
|
||||
a = np.array([[0.0, 50.0]])
|
||||
b = np.array([[100.0, 50.0]])
|
||||
d2 = _pt_to_segment_d2(p, a, b)
|
||||
assert d2[0] <= 2.0 ** 2 # well inside a 5-pixel test tolerance
|
||||
assert d2[0] <= 2.0**2 # well inside a 5-pixel test tolerance
|
||||
|
||||
|
||||
def test_point_to_segment_orthogonal_miss() -> None:
|
||||
"""Clicking far from a short frame must produce a large distance."""
|
||||
p = np.array([50.0, 200.0])
|
||||
a = np.array([[0.0, 50.0]])
|
||||
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
|
||||
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
|
||||
d2 = _pt_to_segment_d2(p, a, b)
|
||||
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
|
||||
assert d2[0] > 150.0 ** 2
|
||||
assert d2[0] > 150.0**2
|
||||
|
||||
|
||||
def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
|
|
@ -226,6 +236,7 @@ def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-
|
|||
grid bounds so the node remains visible (regression guard)."""
|
||||
from otko.core import Node
|
||||
from otko.views.canvas3d.model_canvas import ModelCanvas
|
||||
|
||||
canvas = ModelCanvas()
|
||||
qtbot.addWidget(canvas)
|
||||
p = Project(
|
||||
|
|
|
|||
|
|
@ -77,17 +77,27 @@ def test_add_nodes_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untype
|
|||
@pytest.mark.gui
|
||||
def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
Node(id=3, coords=(2, 0, 0)),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddNodesCommand(
|
||||
vm,
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
Node(id=3, coords=(2, 0, 0)),
|
||||
],
|
||||
)
|
||||
)
|
||||
# Add a material so the element is valid.
|
||||
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
|
||||
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
# Delete node 2 → both elements should disappear.
|
||||
vm.apply_command(DeleteNodesCommand(vm, {2}))
|
||||
assert {n.id for n in vm.project.nodes} == {1, 3}
|
||||
|
|
@ -102,10 +112,15 @@ def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-unt
|
|||
@pytest.mark.gui
|
||||
def test_set_restraint_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm()
|
||||
vm.apply_command(AddNodesCommand(vm, [
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddNodesCommand(
|
||||
vm,
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(1, 0, 0)),
|
||||
],
|
||||
)
|
||||
)
|
||||
fix = (True, True, True, True, True, True)
|
||||
vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix))
|
||||
assert vm.project.node(1).restraint == fix
|
||||
|
|
|
|||
|
|
@ -26,32 +26,62 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
|
|||
def _vm_setup() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.apply_command(AddNodesCommand(vm, [
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
]))
|
||||
vm.apply_command(AddMaterialsCommand(vm, [
|
||||
ElasticUniaxial(id=1, name="Steel", E=200e9),
|
||||
]))
|
||||
vm.apply_command(AddSectionsCommand(vm, [
|
||||
ElasticSection(
|
||||
id=1, name="Default", E=200e9, A=0.01,
|
||||
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6,
|
||||
),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddNodesCommand(
|
||||
vm,
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
AddMaterialsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticUniaxial(id=1, name="Steel", E=200e9),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
AddSectionsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticSection(
|
||||
id=1,
|
||||
name="Default",
|
||||
E=200e9,
|
||||
A=0.01,
|
||||
Iz=8.33e-6,
|
||||
Iy=8.33e-6,
|
||||
G=80e9,
|
||||
J=1e-6,
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_setup()
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
]))
|
||||
vm.apply_command(ConvertElementTypeCommand(
|
||||
vm, {1}, "Truss",
|
||||
defaults={"material_id": 1, "area": 0.001},
|
||||
))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{1},
|
||||
"Truss",
|
||||
defaults={"material_id": 1, "area": 0.001},
|
||||
)
|
||||
)
|
||||
el = vm.project.element(1)
|
||||
assert isinstance(el, TrussElement)
|
||||
assert el.nodes == (1, 2)
|
||||
|
|
@ -62,13 +92,22 @@ def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-de
|
|||
@pytest.mark.gui
|
||||
def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_setup()
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
]))
|
||||
vm.apply_command(ConvertElementTypeCommand(
|
||||
vm, {1}, "ElasticBeamColumn",
|
||||
defaults={"section_id": 1},
|
||||
))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{1},
|
||||
"ElasticBeamColumn",
|
||||
defaults={"section_id": 1},
|
||||
)
|
||||
)
|
||||
el = vm.project.element(1)
|
||||
assert isinstance(el, ElasticBeamColumn)
|
||||
assert el.nodes == (1, 2)
|
||||
|
|
@ -78,12 +117,22 @@ def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-de
|
|||
@pytest.mark.gui
|
||||
def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_setup()
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
|
||||
]))
|
||||
vm.apply_command(ConvertElementTypeCommand(
|
||||
vm, {42}, "Truss", defaults={"material_id": 1, "area": 0.002},
|
||||
))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{42},
|
||||
"Truss",
|
||||
defaults={"material_id": 1, "area": 0.002},
|
||||
)
|
||||
)
|
||||
assert isinstance(vm.project.element(42), TrussElement)
|
||||
assert vm.project.element(42).name == "A"
|
||||
vm.undo_stack.undo()
|
||||
|
|
@ -93,13 +142,23 @@ def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no
|
|||
@pytest.mark.gui
|
||||
def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_setup()
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
before = vm.project.element(1)
|
||||
vm.apply_command(ConvertElementTypeCommand(
|
||||
vm, {1}, "Truss", defaults={"material_id": 1, "area": 0.002},
|
||||
))
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{1},
|
||||
"Truss",
|
||||
defaults={"material_id": 1, "area": 0.002},
|
||||
)
|
||||
)
|
||||
# Class is still Truss and the element object is unchanged.
|
||||
assert vm.project.element(1) is before
|
||||
|
||||
|
|
@ -107,10 +166,20 @@ def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-de
|
|||
@pytest.mark.gui
|
||||
def test_unknown_target_type_raises(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
vm = _vm_setup()
|
||||
vm.apply_command(AddElementsCommand(vm, [
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddElementsCommand(
|
||||
vm,
|
||||
[
|
||||
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
vm.apply_command(ConvertElementTypeCommand(
|
||||
vm, {1}, "NotAType", defaults={},
|
||||
))
|
||||
vm.apply_command(
|
||||
ConvertElementTypeCommand(
|
||||
vm,
|
||||
{1},
|
||||
"NotAType",
|
||||
defaults={},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ def test_linear_time_series_dialog_builds_entity(qtbot) -> None: # type: ignore
|
|||
|
||||
@pytest.mark.gui
|
||||
def test_plain_pattern_dialog_uses_selected_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
proj = Project(time_series=[
|
||||
LinearTimeSeries(id=1, name="GravityTS"),
|
||||
LinearTimeSeries(id=2, name="RampTS"),
|
||||
])
|
||||
proj = Project(
|
||||
time_series=[
|
||||
LinearTimeSeries(id=1, name="GravityTS"),
|
||||
LinearTimeSeries(id=2, name="RampTS"),
|
||||
]
|
||||
)
|
||||
dlg = PlainPatternDialog(project=proj, next_pattern_id=4)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,10 +35,15 @@ class _CanvasStub:
|
|||
def _vm_with_two_nodes() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.apply_command(AddNodesCommand(vm, [
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
]))
|
||||
vm.apply_command(
|
||||
AddNodesCommand(
|
||||
vm,
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
],
|
||||
)
|
||||
)
|
||||
return vm
|
||||
|
||||
|
||||
|
|
@ -86,7 +91,7 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(1) # same node — should NOT create an element
|
||||
tool.on_node_picked(1) # same node — should NOT create an element
|
||||
assert vm.project.elements == []
|
||||
# First-node state preserved so user can finish the gesture.
|
||||
assert tool._first_node_id == 1
|
||||
|
|
@ -150,6 +155,7 @@ def test_reset_clears_first_pick(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Two clicks on empty grid intersections → 2 nodes + 1 frame."""
|
||||
from otko.core import GridSystem
|
||||
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
|
||||
|
|
@ -165,9 +171,9 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
|
|||
tool.on_empty_clicked(0.0, 0.0, 0.0)
|
||||
tool.on_empty_clicked(3.0, 4.0, 0.0)
|
||||
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
elem = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
elem = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert isinstance(elem, ElasticBeamColumn)
|
||||
n1 = next(n for n in vm.project.nodes if n.id == elem.nodes[0]) # type: ignore[union-attr]
|
||||
n2 = next(n for n in vm.project.nodes if n.id == elem.nodes[1]) # type: ignore[union-attr]
|
||||
|
|
@ -179,18 +185,21 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
|
|||
def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""An empty click at an existing node's location must not duplicate it."""
|
||||
from otko.core import GridSystem
|
||||
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
|
||||
|
||||
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
|
||||
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
|
||||
x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0],
|
||||
x_lines=[0.0, 3.0],
|
||||
y_lines=[0.0],
|
||||
z_lines=[0.0],
|
||||
)
|
||||
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
|
||||
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
|
||||
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
|
||||
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
|
||||
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
|
||||
elem = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
|
||||
elem = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert set(elem.nodes) == {1, 2}
|
||||
|
||||
|
||||
|
|
@ -198,19 +207,22 @@ def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-u
|
|||
def test_mixed_node_pick_then_empty_click(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""First click picks existing node; second click creates new node + frame."""
|
||||
from otko.core import GridSystem
|
||||
|
||||
vm = _vm_with_two_nodes()
|
||||
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
|
||||
x_lines=[0.0, 3.0, 6.0], y_lines=[0.0], z_lines=[0.0],
|
||||
x_lines=[0.0, 3.0, 6.0],
|
||||
y_lines=[0.0],
|
||||
z_lines=[0.0],
|
||||
)
|
||||
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
|
||||
tool.on_node_picked(1) # start at node 1 = (0,0,0)
|
||||
tool.on_node_picked(1) # start at node 1 = (0,0,0)
|
||||
tool.on_empty_clicked(6.0, 0.0, 0.0) # canvas emits exact snap
|
||||
|
||||
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
|
||||
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
|
||||
assert len(vm.project.elements) == 1
|
||||
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
|
||||
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
|
||||
assert new_node.coords == (6.0, 0.0, 0.0)
|
||||
elem = vm.project.elements[0]
|
||||
assert set(elem.nodes) == {1, new_node.id}
|
||||
|
|
|
|||
|
|
@ -46,8 +46,12 @@ def _vm_with_grid() -> ProjectViewModel:
|
|||
# ────────────────────────── snap helper ─────────────────────────────
|
||||
def test_snap_to_grid_picks_nearest_lines() -> None:
|
||||
x, y, z = _snap_to_grid(
|
||||
2.2, 3.7, 0.6,
|
||||
x_lines=[0.0, 3.0], y_lines=[0.0, 4.0], z_lines=[0.0, 3.0],
|
||||
2.2,
|
||||
3.7,
|
||||
0.6,
|
||||
x_lines=[0.0, 3.0],
|
||||
y_lines=[0.0, 4.0],
|
||||
z_lines=[0.0, 3.0],
|
||||
)
|
||||
assert (x, y, z) == (3.0, 4.0, 0.0)
|
||||
|
||||
|
|
@ -99,10 +103,11 @@ def test_grid_less_canvas_never_emits_to_tool(qtbot) -> None: # type: ignore[no
|
|||
target. For a grid-less project, the canvas never emits, so the
|
||||
tool is never invoked. We therefore don't test rejection here.
|
||||
"""
|
||||
vm = ProjectViewModel(); vm.new_project()
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
tool = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
assert vm.project.nodes == [] # type: ignore[union-attr]
|
||||
assert vm.project.nodes == [] # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ class _CanvasStub:
|
|||
def _vm_with_grid_and_nodes() -> ProjectViewModel:
|
||||
vm = ProjectViewModel()
|
||||
vm.new_project()
|
||||
vm.project.nodes.extend([
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
])
|
||||
vm.project.nodes.extend(
|
||||
[
|
||||
Node(id=1, coords=(0, 0, 0)),
|
||||
Node(id=2, coords=(3, 0, 0)),
|
||||
]
|
||||
)
|
||||
vm.project.coord_systems = [
|
||||
CoordinateGridSystem(
|
||||
name="Global",
|
||||
|
|
@ -60,13 +62,13 @@ def test_two_clicks_create_truss(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
el = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
el = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert isinstance(el, TrussElement)
|
||||
assert el.nodes == (1, 2)
|
||||
assert el.area > 0
|
||||
# A default ElasticUniaxial must have been created for the material.
|
||||
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
|
||||
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
|
||||
assert isinstance(vm.project.materials[0], ElasticUniaxial)
|
||||
|
||||
|
||||
|
|
@ -78,9 +80,9 @@ def test_draw_truss_reuses_existing_material(qtbot) -> None: # type: ignore[no-
|
|||
tool.activate()
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(2)
|
||||
el = vm.project.elements[0] # type: ignore[union-attr]
|
||||
el = vm.project.elements[0] # type: ignore[union-attr]
|
||||
assert el.material_id == 1
|
||||
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
|
||||
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
|
|
@ -104,8 +106,8 @@ def test_empty_click_creates_node_then_truss(qtbot) -> None: # type: ignore[no-
|
|||
tool.on_empty_clicked(3.0, 0.0, 0.0)
|
||||
|
||||
# Two nodes created + one truss element.
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
|
||||
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
|
||||
assert isinstance(vm.project.elements[0], TrussElement)
|
||||
|
||||
|
||||
|
|
@ -115,5 +117,5 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
|
||||
tool.activate()
|
||||
tool.on_node_picked(1)
|
||||
tool.on_node_picked(1) # same node — must not create a zero-length truss
|
||||
assert vm.project.elements == [] # type: ignore[union-attr]
|
||||
tool.on_node_picked(1) # same node — must not create a zero-length truss
|
||||
assert vm.project.elements == [] # type: ignore[union-attr]
|
||||
|
|
|
|||
|
|
@ -19,11 +19,18 @@ from otko.core import ( # noqa: E402
|
|||
@pytest.mark.gui
|
||||
def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
||||
|
||||
dlg = QuickGridLinesDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._x_n.setValue(4); dlg._x_s.setValue(3.0); dlg._x_f.setValue(0.0)
|
||||
dlg._y_n.setValue(2); dlg._y_s.setValue(4.0); dlg._y_f.setValue(0.0)
|
||||
dlg._z_n.setValue(3); dlg._z_s.setValue(3.0); dlg._z_f.setValue(-3.0)
|
||||
dlg._x_n.setValue(4)
|
||||
dlg._x_s.setValue(3.0)
|
||||
dlg._x_f.setValue(0.0)
|
||||
dlg._y_n.setValue(2)
|
||||
dlg._y_s.setValue(4.0)
|
||||
dlg._y_f.setValue(0.0)
|
||||
dlg._z_n.setValue(3)
|
||||
dlg._z_s.setValue(3.0)
|
||||
dlg._z_f.setValue(-3.0)
|
||||
xs, ys, zs = dlg.ordinates()
|
||||
assert xs == [0.0, 3.0, 6.0, 9.0]
|
||||
assert ys == [0.0, 4.0]
|
||||
|
|
@ -33,6 +40,7 @@ def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-
|
|||
@pytest.mark.gui
|
||||
def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
|
||||
|
||||
dlg = QuickGridLinesDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg._x_n.setValue(0)
|
||||
|
|
@ -48,6 +56,7 @@ def test_locate_origin_round_trip(qtbot) -> None: # type: ignore[no-untyped-def
|
|||
from otko.views.dialogs.locate_origin import (
|
||||
CoordSystemLocationOrientationDialog,
|
||||
)
|
||||
|
||||
dlg = CoordSystemLocationOrientationDialog(
|
||||
origin=(1.5, -2.0, 3.0),
|
||||
rotation_deg=(0.0, 0.0, 45.0),
|
||||
|
|
@ -65,6 +74,7 @@ def test_define_grid_data_round_trip(qtbot) -> None: # type: ignore[no-untyped-
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
cs = CoordinateGridSystem(
|
||||
name="Floor2",
|
||||
coord=CoordinateSystem(origin=(0, 0, 3.5)),
|
||||
|
|
@ -96,6 +106,7 @@ def test_define_grid_data_spacing_mode_conversion(qtbot) -> None: # type: ignor
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
cs = CoordinateGridSystem(
|
||||
name="Test",
|
||||
grid=GridSystem(x_grid_lines=make_grid_lines("X", [0.0, 3.0, 6.0, 10.0])),
|
||||
|
|
@ -117,6 +128,7 @@ def test_define_grid_data_global_name_locked(qtbot) -> None: # type: ignore[no-
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog(is_global=True)
|
||||
qtbot.addWidget(dlg)
|
||||
assert dlg._name_edit.text() == "Global"
|
||||
|
|
@ -130,6 +142,7 @@ def test_define_grid_data_add_and_delete_row(qtbot) -> None: # type: ignore[no-
|
|||
from otko.views.dialogs.define_grid_data import (
|
||||
DefineGridSystemDataDialog,
|
||||
)
|
||||
|
||||
dlg = DefineGridSystemDataDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
# Start with empty X table.
|
||||
|
|
|
|||
|
|
@ -20,3 +20,22 @@ def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
|
|||
window.show()
|
||||
assert window.isVisible()
|
||||
assert window.windowTitle() == "OTKO"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignore[no-untyped-def]
|
||||
"""Closing a dirty but hidden window must not raise a modal prompt.
|
||||
|
||||
Qt closes hidden widgets during teardown (as ``pytest-qt`` does); a
|
||||
modal "unsaved changes" dialog there would block forever with no user
|
||||
to answer it. The prompt is therefore gated on visibility.
|
||||
"""
|
||||
from otko.views.main_window import MainWindow
|
||||
|
||||
window = MainWindow()
|
||||
qtbot.addWidget(window)
|
||||
window._vm.new_project()
|
||||
window._vm.mark_dirty()
|
||||
assert not window.isVisible()
|
||||
assert window.close() is True
|
||||
assert window._vm.is_dirty # unchanged: the prompt was skipped
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue