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.
This commit is contained in:
smillmorel 2026-09-16 12:03:22 -04:00
commit ba783718d4
152 changed files with 3394 additions and 1651 deletions

View file

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

View file

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

View file

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

View file

@ -13,8 +13,9 @@ if TYPE_CHECKING:
class AddAnalysisCasesCommand(ProjectCommand): class AddAnalysisCasesCommand(ProjectCommand):
"""Add one or more analysis cases.""" """Add one or more analysis cases."""
def __init__(self, vm: "ProjectViewModel", cases: list[Any], *, def __init__(
text: str | None = None) -> None: self, vm: "ProjectViewModel", cases: list[Any], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(cases)} analysis case(s)") super().__init__(vm, text or f"Add {len(cases)} analysis case(s)")
self._cases = list(cases) self._cases = list(cases)
@ -42,12 +43,9 @@ class DeleteAnalysisCasesCommand(ProjectCommand):
def redo(self) -> None: def redo(self) -> None:
self._removed = [ self._removed = [
(i, c) for i, c in enumerate(self.project.analyses) (i, c) for i, c in enumerate(self.project.analyses) if c.id in self._case_ids
if c.id in self._case_ids
]
self.project.analyses[:] = [
c for c in self.project.analyses if c.id not in self._case_ids
] ]
self.project.analyses[:] = [c for c in self.project.analyses if c.id not in self._case_ids]
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:

View file

@ -38,9 +38,7 @@ class ProjectCommand(QUndoCommand):
@property @property
def project(self) -> "Project": def project(self) -> "Project":
if self._vm.project is None: if self._vm.project is None:
raise RuntimeError( raise RuntimeError(f"Cannot apply '{self.text()}': no active project.")
f"Cannot apply '{self.text()}': no active project."
)
return self._vm.project return self._vm.project
def _notify(self) -> None: def _notify(self) -> None:

View file

@ -34,8 +34,7 @@ class AddEqualDOFConstraintCommand(ProjectCommand):
def undo(self) -> None: def undo(self) -> None:
self.project.mp_constraints[:] = [ self.project.mp_constraints[:] = [
mp for mp in self.project.mp_constraints mp for mp in self.project.mp_constraints if mp != self._constraint
if mp != self._constraint
] ]
self._notify() self._notify()

View file

@ -47,7 +47,7 @@ class AddNodalLoadsCommand(ProjectCommand):
self._forces = forces self._forces = forces
self._pattern_id = pattern_id self._pattern_id = pattern_id
self._new_pattern_name = new_pattern_name 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_ts: TimeSeries | None = None
self._created_pattern: PlainLoadPattern | None = None self._created_pattern: PlainLoadPattern | None = None
self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load) self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load)
@ -101,7 +101,10 @@ class AddNodalLoadsCommand(ProjectCommand):
break break
self._added_loads.clear() self._added_loads.clear()
# Roll back any infrastructure we created. # Roll back any infrastructure we created.
if 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.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series: if self._created_ts is not None and self._created_ts in self.project.time_series:
@ -157,7 +160,10 @@ class AddElementLoadsCommand(ProjectCommand):
pattern = self._resolve_pattern() pattern = self._resolve_pattern()
for eid in self._element_ids: for eid in self._element_ids:
load = UniformElementLoad( load = UniformElementLoad(
element_id=eid, 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) pattern.element_loads.append(load)
self._added_loads.append((pattern.id, load)) self._added_loads.append((pattern.id, load))
@ -171,7 +177,10 @@ class AddElementLoadsCommand(ProjectCommand):
pat.element_loads.remove(load) pat.element_loads.remove(load)
break break
self._added_loads.clear() 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.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series: if self._created_ts is not None and self._created_ts in self.project.time_series:

View file

@ -13,7 +13,9 @@ if TYPE_CHECKING:
class AddMaterialsCommand(ProjectCommand): class AddMaterialsCommand(ProjectCommand):
"""Add one or more materials in a single undoable step.""" """Add one or more materials in a single undoable step."""
def __init__(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)") super().__init__(vm, text or f"Add {len(materials)} material(s)")
self._materials = list(materials) self._materials = list(materials)
@ -41,8 +43,7 @@ class DeleteMaterialsCommand(ProjectCommand):
def redo(self) -> None: def redo(self) -> None:
self._removed = [ self._removed = [
(i, m) for i, m in enumerate(self.project.materials) (i, m) for i, m in enumerate(self.project.materials) if m.id in self._material_ids
if m.id in self._material_ids
] ]
self.project.materials[:] = [ self.project.materials[:] = [
m for m in self.project.materials if m.id not in self._material_ids m for m in self.project.materials if m.id not in self._material_ids

View file

@ -14,7 +14,9 @@ if TYPE_CHECKING:
class AddNodesCommand(ProjectCommand): class AddNodesCommand(ProjectCommand):
"""Add one or more nodes in a single undoable step.""" """Add one or more nodes in a single undoable step."""
def __init__(self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None) -> None: def __init__(
self, vm: "ProjectViewModel", nodes: list[Node], *, text: str | None = None
) -> None:
super().__init__(vm, text or f"Add {len(nodes)} node(s)") super().__init__(vm, text or f"Add {len(nodes)} node(s)")
self._nodes = list(nodes) self._nodes = list(nodes)
@ -42,14 +44,15 @@ class DeleteNodesCommand(ProjectCommand):
def __init__(self, vm: "ProjectViewModel", node_ids: set[int]) -> None: def __init__(self, vm: "ProjectViewModel", node_ids: set[int]) -> None:
super().__init__(vm, f"Delete {len(node_ids)} node(s)") super().__init__(vm, f"Delete {len(node_ids)} node(s)")
self._node_ids = set(node_ids) self._node_ids = set(node_ids)
self._removed_nodes: list[tuple[int, Node]] = [] # (index, node) self._removed_nodes: list[tuple[int, Node]] = [] # (index, node)
self._removed_elements: list[tuple[int, object]] = [] # (index, element) self._removed_elements: list[tuple[int, object]] = [] # (index, element)
self._removed_mp_constraints: list[tuple[int, object]] = [] self._removed_mp_constraints: list[tuple[int, object]] = []
def redo(self) -> None: def redo(self) -> None:
# Cascade: snapshot every element that references a doomed node. # Cascade: snapshot every element that references a doomed node.
self._removed_elements = [ self._removed_elements = [
(i, el) 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) if any(nid in self._node_ids for nid in el.nodes)
] ]
doomed_elem_ids = {el.id for _, el in self._removed_elements} doomed_elem_ids = {el.id for _, el in self._removed_elements}
@ -58,7 +61,8 @@ class DeleteNodesCommand(ProjectCommand):
] ]
self._removed_mp_constraints = [ 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 if mp.retained_node in self._node_ids or mp.constrained_node in self._node_ids
] ]
doomed_mp = {id(mp) for _, mp in self._removed_mp_constraints} doomed_mp = {id(mp) for _, mp in self._removed_mp_constraints}
@ -69,9 +73,7 @@ class DeleteNodesCommand(ProjectCommand):
self._removed_nodes = [ self._removed_nodes = [
(i, n) for i, n in enumerate(self.project.nodes) if n.id in self._node_ids (i, n) for i, n in enumerate(self.project.nodes) if n.id in self._node_ids
] ]
self.project.nodes[:] = [ self.project.nodes[:] = [n for n in self.project.nodes if n.id not in self._node_ids]
n for n in self.project.nodes if n.id not in self._node_ids
]
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:

View file

@ -13,7 +13,9 @@ if TYPE_CHECKING:
class AddSectionsCommand(ProjectCommand): class AddSectionsCommand(ProjectCommand):
"""Add one or more sections in a single undoable step.""" """Add one or more sections in a single undoable step."""
def __init__(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)") super().__init__(vm, text or f"Add {len(sections)} section(s)")
self._sections = list(sections) self._sections = list(sections)
@ -41,8 +43,7 @@ class DeleteSectionsCommand(ProjectCommand):
def redo(self) -> None: def redo(self) -> None:
self._removed = [ self._removed = [
(i, s) for i, s in enumerate(self.project.sections) (i, s) for i, s in enumerate(self.project.sections) if s.id in self._section_ids
if s.id in self._section_ids
] ]
self.project.sections[:] = [ self.project.sections[:] = [
s for s in self.project.sections if s.id not in self._section_ids s for s in self.project.sections if s.id not in self._section_ids

View file

@ -31,9 +31,7 @@ class AddTimeSeriesCommand(ProjectCommand):
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:
self.project.time_series[:] = [ self.project.time_series[:] = [t for t in self.project.time_series if t.id != self._ts.id]
t for t in self.project.time_series if t.id != self._ts.id
]
self._notify() self._notify()

View file

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

View file

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

View file

@ -16,11 +16,13 @@ class Entity(BaseModel):
"""Base class for every persisted domain object.""" """Base class for every persisted domain object."""
model_config = ConfigDict( model_config = ConfigDict(
frozen=False, # individual setters allowed; we lock at the Project boundary frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore extra="forbid", # unknown JSON keys are an error, not a silent ignore
validate_assignment=True, validate_assignment=True,
populate_by_name=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.") name: str = Field(default="", description="Optional human-readable label.")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,9 @@ class Steel02(Entity):
Fy: PositiveFloat Fy: PositiveFloat
E0: PositiveFloat E0: PositiveFloat
b: float = Field(..., ge=0.0, le=1.0) b: float = Field(..., ge=0.0, le=1.0)
R0: float = Field(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) cR1: float = Field(default=0.925)
cR2: float = Field(default=0.15) cR2: float = Field(default=0.15)
@ -83,7 +85,10 @@ class Concrete02(Entity):
fpcu: float = Field(..., le=0.0) fpcu: float = Field(..., le=0.0)
epsU: float = Field(..., lt=0.0) epsU: float = Field(..., lt=0.0)
lambda_: float = Field( lambda_: float = Field(
..., alias="lambda", ge=0.0, le=1.0, ...,
alias="lambda",
ge=0.0,
le=1.0,
description="Ratio between unloading slope at epscu and initial slope.", description="Ratio between unloading slope at epscu and initial slope.",
) )
ft: PositiveFloat = Field(..., description="Tensile strength.") ft: PositiveFloat = Field(..., description="Tensile strength.")
@ -108,15 +113,19 @@ class Concrete04(Entity):
epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).") epscu: float = Field(..., lt=0.0, description="Ultimate compressive strain (negative).")
Ec: PositiveFloat = Field(..., description="Initial tangent modulus.") Ec: PositiveFloat = Field(..., description="Initial tangent modulus.")
fct: float | None = Field( fct: float | None = Field(
default=None, gt=0.0, default=None,
gt=0.0,
description="Maximum tensile strength. Omit for no-tension model.", description="Maximum tensile strength. Omit for no-tension model.",
) )
et: float | None = Field( et: float | None = Field(
default=None, gt=0.0, default=None,
gt=0.0,
description="Ultimate tensile strain. Required when fct is given.", description="Ultimate tensile strain. Required when fct is given.",
) )
beta: float | None = Field( beta: float | None = Field(
default=None, 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.", description="Cyclic degradation factor on unloading stiffness. Requires fct and et.",
) )
@ -138,7 +147,8 @@ class ElasticPP(Entity):
E: PositiveFloat E: PositiveFloat
epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.") epsy_pos: PositiveFloat = Field(..., description="Yield strain in tension.")
epsy_neg: float | None = Field( epsy_neg: float | None = Field(
default=None, 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.") 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.") d1: float = Field(default=0.0, ge=0.0, description="Ductility damage, linear portion.")
d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.") d2: float = Field(default=0.0, ge=0.0, description="Ductility damage, cumulative portion.")
beta: float = Field( beta: float = Field(
default=0.0, ge=0.0, default=0.0,
ge=0.0,
description="Unloading-stiffness degradation (0 = no degradation).", description="Unloading-stiffness degradation (0 = no degradation).",
) )
@ -206,7 +217,8 @@ class HystereticSM(Entity):
type: Literal["HystereticSM"] = "HystereticSM" type: Literal["HystereticSM"] = "HystereticSM"
pos_env: list[tuple[float, float]] = Field( pos_env: list[tuple[float, float]] = Field(
..., min_length=1, ...,
min_length=1,
description="Positive envelope (force, deformation) pairs, force first.", description="Positive envelope (force, deformation) pairs, force first.",
) )
neg_env: list[tuple[float, float]] = Field( neg_env: list[tuple[float, float]] = Field(

View file

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

View file

@ -75,8 +75,12 @@ class ElasticSection(Entity):
A: PositiveFloat A: PositiveFloat
Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.") Iz: PositiveFloat = Field(..., description="Moment of inertia about local z-axis.")
Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.") Iy: PositiveFloat | None = Field(default=None, description="Required for 3D frames.")
G: PositiveFloat | None = Field(default=None, description="Shear modulus; required for 3D frames.") G: PositiveFloat | None = Field(
J: PositiveFloat | None = Field(default=None, description="Torsional constant; required for 3D frames.") default=None, description="Shear modulus; required for 3D frames."
)
J: PositiveFloat | None = Field(
default=None, description="Torsional constant; required for 3D frames."
)
shape: SectionShape | None = Field( shape: SectionShape | None = Field(
default=None, default=None,
description=( description=(
@ -198,7 +202,8 @@ class AggregatorDOF(BaseModel):
material_id: PositiveInt material_id: PositiveInt
dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field( dof: Literal["P", "Mz", "My", "Vy", "Vz", "T"] = Field(
..., description="Section DOF code (OpenSees section-deformation names).", ...,
description="Section DOF code (OpenSees section-deformation names).",
) )

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -46,7 +46,8 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
break break
# New NGA format: "3930 0.00500 NPTS, DT" # New NGA format: "3930 0.00500 NPTS, DT"
m_new = re.match( m_new = re.match(
r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT", stripped, r"^(\d+)\s+([0-9.eE+\-]+)\s+NPTS\s*,\s*DT",
stripped,
) )
if m_new: if m_new:
npts = int(m_new.group(1)) npts = int(m_new.group(1))
@ -66,7 +67,7 @@ def parse_peer_record(path: str | Path) -> tuple[float, int, list[float]]:
try: try:
values.append(float(tok)) values.append(float(tok))
except ValueError: except ValueError:
pass # skip stray tokens pass # skip stray tokens
if not values: if not values:
raise ValueError("Header parsed but no numeric data lines found.") raise ValueError("Header parsed but no numeric data lines found.")
return dt, npts, values return dt, npts, values

View file

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

View file

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

View file

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

View file

@ -35,9 +35,9 @@ class SectionProps:
area: float area: float
centroid_y: float centroid_y: float
centroid_z: float centroid_z: float
Iy: float # about centroid Iy: float # about centroid
Iz: float # about centroid Iz: float # about centroid
fibre_yz: np.ndarray # (n, 3): y, z, area fibre_yz: np.ndarray # (n, 3): y, z, area
def expand_fibres(sec: FiberSection) -> np.ndarray: 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) yc = p.y_center + r_mid * math.cos(theta)
zc = p.z_center + r_mid * math.sin(theta) zc = p.z_center + r_mid * math.sin(theta)
# Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad) # Annular sector area: (r_outer² - r_inner²) * dθ / (2·n_rad)
a = ((p.r_inner + (ir + 1) * d_r) ** 2 a = (
- (p.r_inner + ir * d_r) ** 2) \ ((p.r_inner + (ir + 1) * d_r) ** 2 - (p.r_inner + ir * d_r) ** 2)
* math.radians(d_theta) / 2.0 * math.radians(d_theta)
/ 2.0
)
rows.append((yc, zc, a)) rows.append((yc, zc, a))
for lay in sec.layers: for lay in sec.layers:
@ -102,8 +104,8 @@ def compute_section_props(sec: FiberSection) -> SectionProps:
zc = float(np.sum(a * z) / total_a) zc = float(np.sum(a * z) / total_a)
# Second moments of area about centroid (parallel axis from each fibre). # Second moments of area about centroid (parallel axis from each fibre).
Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis Iz = float(np.sum(a * (y - yc) ** 2)) # about z-axis
Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis Iy = float(np.sum(a * (z - zc) ** 2)) # about y-axis
return SectionProps( return SectionProps(
n_fibres=len(fibres), n_fibres=len(fibres),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -80,9 +80,7 @@ class AssignLoadDialog(QDialog):
pf.addRow("Active:", self._active_pattern_label) pf.addRow("Active:", self._active_pattern_label)
self._new_name_edit = QLineEdit("Pattern") self._new_name_edit = QLineEdit("Pattern")
self._new_name_edit.setPlaceholderText( self._new_name_edit.setPlaceholderText("Name for the new pattern (e.g. RefMoment)")
"Name for the new pattern (e.g. RefMoment)"
)
pf.addRow("New name:", self._new_name_edit) pf.addRow("New name:", self._new_name_edit)
# TimeSeries type selector — only used when creating a new # TimeSeries type selector — only used when creating a new

View file

@ -56,16 +56,22 @@ class AssignMassesDialog(QDialog):
cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) cur = self._current or (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
form = QFormLayout() form = QFormLayout()
self._mx = self._spin(cur[0]); form.addRow("Translation X:", self._mx) self._mx = self._spin(cur[0])
self._my = self._spin(cur[1]); form.addRow("Translation Y:", self._my) form.addRow("Translation X:", self._mx)
self._my = self._spin(cur[1])
form.addRow("Translation Y:", self._my)
if self._ndf >= 3: if self._ndf >= 3:
self._mz = self._spin(cur[2]); form.addRow("Translation Z:", self._mz) self._mz = self._spin(cur[2])
form.addRow("Translation Z:", self._mz)
else: else:
self._mz = self._spin(cur[2]) self._mz = self._spin(cur[2])
if self._ndf == 6: if self._ndf == 6:
self._mxx = self._spin(cur[3]); form.addRow("Rotation X (Ixx):", self._mxx) self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4]); form.addRow("Rotation Y (Iyy):", self._myy) form.addRow("Rotation X (Ixx):", self._mxx)
self._mzz = self._spin(cur[5]); form.addRow("Rotation Z (Izz):", self._mzz) 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: else:
self._mxx = self._spin(cur[3]) self._mxx = self._spin(cur[3])
self._myy = self._spin(cur[4]) self._myy = self._spin(cur[4])
@ -77,8 +83,7 @@ class AssignMassesDialog(QDialog):
root.addWidget(self._xy_link) root.addWidget(self._xy_link)
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
| QDialogButtonBox.StandardButton.Cancel,
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@ -106,6 +111,10 @@ class AssignMassesDialog(QDialog):
def mass_vector(self) -> tuple[float, float, float, float, float, float]: def mass_vector(self) -> tuple[float, float, float, float, float, float]:
"""Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz).""" """Return the 6-tuple (Mx, My, Mz, Mxx, Myy, Mzz)."""
return ( return (
self._mx.value(), self._my.value(), self._mz.value(), self._mx.value(),
self._mxx.value(), self._myy.value(), self._mzz.value(), self._my.value(),
self._mz.value(),
self._mxx.value(),
self._myy.value(),
self._mzz.value(),
) )

View file

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

View file

@ -25,10 +25,10 @@ from PySide6.QtWidgets import (
# Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz) # Preset → 6-tuple (Ux, Uy, Uz, Rx, Ry, Rz)
PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = { PRESETS: dict[str, tuple[bool, bool, bool, bool, bool, bool]] = {
"Free": (False, False, False, False, False, False), "Free": (False, False, False, False, False, False),
"Roller (Z)": (False, False, True, False, False, False), "Roller (Z)": (False, False, True, False, False, False),
"Pin": (True, True, True, False, False, False), "Pin": (True, True, True, False, False, False),
"Fix": (True, True, True, True, True, True), "Fix": (True, True, True, True, True, True),
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -150,7 +150,12 @@ class MaterialLibraryDialog(QDialog):
return return
kinds = list(FORM_REGISTRY.keys()) kinds = list(FORM_REGISTRY.keys())
kind, ok = QInputDialog.getItem( 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: if not ok:
return return
@ -179,9 +184,10 @@ class MaterialLibraryDialog(QDialog):
if material is None: if material is None:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
self, "Delete material", self,
"Delete material",
f"Delete material #{material.id} ({material.type})?\n" f"Delete material #{material.id} ({material.type})?\n"
"Elements that reference it will be invalid until reassigned." "Elements that reference it will be invalid until reassigned.",
) )
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,8 +15,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtGui import QIcon from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QMainWindow from PySide6.QtGui import QCloseEvent, QIcon
from PySide6.QtWidgets import QMainWindow, QMessageBox
from otko.viewmodels import AnalysisRunner, ProjectViewModel from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers from otko.views.action_handlers import ActionHandlers
@ -74,6 +75,7 @@ class MainWindow(
self._build_status_bar() self._build_status_bar()
self._wire() self._wire()
self._refresh_action_enablement() self._refresh_action_enablement()
self.restore_layout()
# ── construction ───────────────────────────────────────────────── # ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None: def _build_central_canvas(self) -> None:
@ -82,6 +84,54 @@ class MainWindow(
# Diagram overlay paints onto the same plotter as the model. # Diagram overlay paints onto the same plotter as the model.
self._diagram_renderer = DiagramRenderer(self._canvas) self._diagram_renderer = DiagramRenderer(self._canvas)
# ── layout persistence ───────────────────────────────────────────
def save_layout(self) -> None:
"""Persist window geometry and dock/toolbar state for next launch."""
settings = QSettings("OTKO", "OTKO")
settings.setValue("geometry", self.saveGeometry())
settings.setValue("windowState", self.saveState())
def restore_layout(self) -> None:
"""Restore a previously saved geometry/dock layout, if any.
On first run no values are stored, so the defaults from ``__init__``
(``resize`` + dock construction) stay in effect.
"""
settings = QSettings("OTKO", "OTKO")
geometry = settings.value("geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = settings.value("windowState")
if state is not None:
self.restoreState(state)
def closeEvent(self, event: QCloseEvent) -> None: # 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 ──────────────────────────────────────────────────────── # ── wiring ────────────────────────────────────────────────────────
def _wire(self) -> None: def _wire(self) -> None:
# File # File
@ -141,6 +191,7 @@ class MainWindow(
# Analyze # Analyze
self._act_case_manager.triggered.connect(self._on_case_manager) self._act_case_manager.triggered.connect(self._on_case_manager)
self._act_combo_manager.triggered.connect(self._on_combo_manager)
self._act_run.triggered.connect(self._on_run_analysis) self._act_run.triggered.connect(self._on_run_analysis)
# Display # Display
@ -156,7 +207,7 @@ class MainWindow(
self._act_back_to_model.triggered.connect(self._on_back_to_model) self._act_back_to_model.triggered.connect(self._on_back_to_model)
# AnalysisRunner: stream log to console + show results in panel # AnalysisRunner: stream log to console + show results in panel
self._runner.log.connect(self._console.appendPlainText) self._runner.log.connect(self._console_dock.log)
self._runner.finished.connect(self._on_analysis_finished) self._runner.finished.connect(self._on_analysis_finished)
self._runner.failed.connect(self._on_analysis_failed) self._runner.failed.connect(self._on_analysis_failed)

View file

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

View file

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

View file

@ -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_node import DrawNodeTool
from otko.views.tools.draw_truss import DrawTrussTool from otko.views.tools.draw_truss import DrawTrussTool
__all__ = ["CanvasTool", "SelectTool", "ToolController", __all__ = [
"DrawFrameTool", "DrawNodeTool", "DrawTrussTool"] "CanvasTool",
"SelectTool",
"ToolController",
"DrawFrameTool",
"DrawNodeTool",
"DrawTrussTool",
]

View file

@ -84,10 +84,11 @@ class SelectTool(CanvasTool):
class ToolController(QObject): class ToolController(QObject):
"""Owns the active tool and routes canvas pick signals to it.""" """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", def __init__(
parent: QObject | None = None) -> None: self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
) -> None:
super().__init__(parent) super().__init__(parent)
self._canvas = canvas self._canvas = canvas
self._vm = vm self._vm = vm

View file

@ -67,7 +67,9 @@ class DrawFrameTool(LinearElementDrawTool):
section_id = self._ensure_default_section() section_id = self._ensure_default_section()
element_id = project.next_element_id() element_id = project.next_element_id()
elem = ElasticBeamColumn( elem = ElasticBeamColumn(
id=element_id, 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])) self._vm.apply_command(AddElementsCommand(self._vm, [elem]))
finally: finally:

View file

@ -27,14 +27,20 @@ if TYPE_CHECKING:
def _snap_to_grid( def _snap_to_grid(
x: float, y: float, z: float, x: float,
x_lines: list[float], y_lines: list[float], z_lines: list[float], y: float,
z: float,
x_lines: list[float],
y_lines: list[float],
z_lines: list[float],
) -> tuple[float, float, float]: ) -> tuple[float, float, float]:
"""Snap each coordinate to its closest grid line (identity on empty axes).""" """Snap each coordinate to its closest grid line (identity on empty axes)."""
def nearest(v: float, lines: list[float]) -> float: def nearest(v: float, lines: list[float]) -> float:
if not lines: if not lines:
return v return v
return min(lines, key=lambda c: abs(c - v)) return min(lines, key=lambda c: abs(c - v))
return nearest(x, x_lines), nearest(y, y_lines), nearest(z, z_lines) 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): if not (grid.x_lines or grid.y_lines or grid.z_lines):
continue continue
lx, ly, lz = cs.coord.world_to_local(world) lx, ly, lz = cs.coord.world_to_local(world)
sl = _snap_to_grid(lx, ly, lz, sl = _snap_to_grid(lx, ly, lz, grid.x_lines, grid.y_lines, grid.z_lines)
grid.x_lines, grid.y_lines, grid.z_lines)
candidate = cs.coord.local_to_world(sl) candidate = cs.coord.local_to_world(sl)
d2 = ((candidate[0] - world[0]) ** 2 d2 = (
+ (candidate[1] - world[1]) ** 2 (candidate[0] - world[0]) ** 2
+ (candidate[2] - world[2]) ** 2) + (candidate[1] - world[1]) ** 2
+ (candidate[2] - world[2]) ** 2
)
if d2 < best_d2: if d2 < best_d2:
best = candidate best = candidate
best_d2 = d2 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: def _min_grid_spacing(systems: list[CoordinateGridSystem]) -> float | None:
@ -124,13 +131,13 @@ class DrawNodeTool(CanvasTool):
super().deactivate() super().deactivate()
def prompt(self) -> str: def prompt(self) -> str:
return ("Draw Node: click a grid intersection " return (
"to place a node. Switch to Select tool to finish.") "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: def on_node_picked(self, node_id: int) -> None:
self.statusChanged.emit( self.statusChanged.emit(f"Draw Node: node {node_id} already exists at that spot.")
f"Draw Node: node {node_id} already exists at that spot."
)
def on_empty_clicked(self, x: float, y: float, z: float) -> None: def on_empty_clicked(self, x: float, y: float, z: float) -> None:
"""Called only when the canvas has already confirmed a grid snap. """Called only when the canvas has already confirmed a grid snap.
@ -153,9 +160,5 @@ class DrawNodeTool(CanvasTool):
nid = project.next_node_id() nid = project.next_node_id()
node = Node(id=nid, coords=(x, y, z)) node = Node(id=nid, coords=(x, y, z))
self._vm.apply_command( self._vm.apply_command(AddNodesCommand(self._vm, [node], text=f"Add node {nid}"))
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.statusChanged.emit(
f"Draw Node: added node {nid} at ({x:g}, {y:g}, {z:g})."
)

View file

@ -1,22 +1,30 @@
"""Shared pytest fixtures. """Shared pytest configuration.
`pytest-qt` automatically provides a `qtbot` fixture and a QApplication The GUI suite is headless by default: unless the caller already chose a
instance. We add convenience fixtures here as the suite grows. 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 from __future__ import annotations
import os
from collections.abc import Iterator
import pytest import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_opensees() -> None: def _wipe_opensees_domain() -> Iterator[None]:
"""Reset OpenSees domain between tests if openseespy is importable. """Wipe the OpenseesPy domain after a test if the solver was imported."""
yield
Imported lazily so that pure-core tests don't pull in the C++ runtime.
"""
try: try:
import openseespy.opensees as ops from openseespy.opensees import wipe
except ImportError: except Exception: # pragma: no cover - solver not installed / import side effects
return return
ops.wipe() wipe()

View file

@ -29,8 +29,9 @@ def _vm_with_pattern() -> ProjectViewModel:
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0))) vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1)) vm.project.time_series.append(LinearTimeSeries(id=1))
vm.project.load_patterns.append( vm.project.load_patterns.append(
PlainLoadPattern(id=1, time_series_id=1, PlainLoadPattern(
nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))]) id=1, time_series_id=1, nodal_loads=[NodalLoad(node_id=1, forces=(100, 0, 0, 0, 0, 0))]
)
) )
return vm 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] def test_add_emits_modelMutated(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern() vm = _vm_with_pattern()
with qtbot.waitSignal(vm.modelMutated, timeout=500): with qtbot.waitSignal(vm.modelMutated, timeout=500):
vm.apply_command(AddAnalysisCasesCommand(vm, [ vm.apply_command(
StaticCase(id=1, pattern_ids=[1]), AddAnalysisCasesCommand(
])) vm,
[
StaticCase(id=1, pattern_ids=[1]),
],
)
)
@pytest.mark.gui @pytest.mark.gui
@ -74,9 +80,14 @@ def test_add_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untyped-def]
@pytest.mark.gui @pytest.mark.gui
def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untyped-def] def test_update_static_changes_n_steps(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern() vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [ vm.apply_command(
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1), 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) new = StaticCase(id=1, name="A", pattern_ids=[1], n_steps=10)
vm.apply_command(UpdateAnalysisCaseCommand(vm, new)) vm.apply_command(UpdateAnalysisCaseCommand(vm, new))
assert vm.project.analyses[0].n_steps == 10 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 @pytest.mark.gui
def test_update_changes_case_type(qtbot) -> None: # type: ignore[no-untyped-def] def test_update_changes_case_type(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern() vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [ vm.apply_command(
StaticCase(id=1, pattern_ids=[1]), AddAnalysisCasesCommand(
])) vm,
[
StaticCase(id=1, pattern_ids=[1]),
],
)
)
swapped = ModalCase(id=1, name="Mode swap", n_modes=5) swapped = ModalCase(id=1, name="Mode swap", n_modes=5)
vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped)) vm.apply_command(UpdateAnalysisCaseCommand(vm, swapped))
assert isinstance(vm.project.analyses[0], ModalCase) 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 @pytest.mark.gui
def test_delete_round_trip(qtbot) -> None: # type: ignore[no-untyped-def] def test_delete_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_pattern() vm = _vm_with_pattern()
vm.apply_command(AddAnalysisCasesCommand(vm, [ vm.apply_command(
StaticCase(id=1, pattern_ids=[1]), AddAnalysisCasesCommand(
ModalCase(id=2), vm,
])) [
StaticCase(id=1, pattern_ids=[1]),
ModalCase(id=2),
],
)
)
vm.apply_command(DeleteAnalysisCasesCommand(vm, {1})) vm.apply_command(DeleteAnalysisCasesCommand(vm, {1}))
assert {c.id for c in vm.project.analyses} == {2} assert {c.id for c in vm.project.analyses} == {2}
vm.undo_stack.undo() vm.undo_stack.undo()

View file

@ -35,21 +35,42 @@ def cantilever_vm() -> ProjectViewModel:
L = 5.0 L = 5.0
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3) vm.new_project(ndm=2, ndf=3)
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
Node(id=1, coords=(0.0, 0.0, 0.0), AddNodesCommand(
restraint=(True, True, False, False, False, True)), vm,
Node(id=2, coords=(L, 0.0, 0.0)), [
])) Node(
vm.apply_command(AddSectionsCommand(vm, [ id=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6), ),
])) Node(id=2, coords=(L, 0.0, 0.0)),
vm.apply_command(AddElementsCommand(vm, [ ],
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), )
])) )
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(AddNodalLoadsCommand(vm, {2}, (0.0, -1000.0, 0.0, 0.0, 0.0, 0.0)))
vm.apply_command(AddAnalysisCasesCommand(vm, [ vm.apply_command(
StaticCase(id=1, name="Cantilever", pattern_ids=[1]), AddAnalysisCasesCommand(
])) vm,
[
StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
],
)
)
return vm return vm

View file

@ -48,10 +48,11 @@ def test_dialog_lists_existing_patterns(qtbot) -> None: # type: ignore[no-untyp
@pytest.mark.gui @pytest.mark.gui
def test_dialog_enables_name_field_for_new_pattern(qtbot) -> None: # type: ignore[no-untyped-def] def test_dialog_enables_name_field_for_new_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
dlg = AssignLoadDialog( dlg = AssignLoadDialog(
n_selected=1, existing_patterns=[(1, "Gravity")], n_selected=1,
existing_patterns=[(1, "Gravity")],
) )
qtbot.addWidget(dlg) 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.selected_pattern_id() is None
assert dlg._new_name_edit.isEnabled() assert dlg._new_name_edit.isEnabled()
dlg._new_name_edit.setText("RefMoment") 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 # Pre-existing 'Gravity' pattern — must NOT be reused when the user
# asks for a new one named 'RefMoment'. # asks for a new one named 'RefMoment'.
vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity")) vm.project.time_series.append(LinearTimeSeries(id=1, name="Gravity"))
vm.project.load_patterns.append(PlainLoadPattern( vm.project.load_patterns.append(
id=1, name="Gravity", time_series_id=1, PlainLoadPattern(
)) id=1,
name="Gravity",
time_series_id=1,
)
)
vm.apply_command(AddNodalLoadsCommand( vm.apply_command(
vm, {1}, (0, 0, 0, 0, 0, 1.0), AddNodalLoadsCommand(
pattern_id=None, new_pattern_name="RefMoment", 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). # Two patterns now: Gravity (id=1) and RefMoment (id=2).
names = [p.name for p in vm.project.load_patterns] names = [p.name for p in vm.project.load_patterns]
assert "Gravity" in names and "RefMoment" in names 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 the new pattern — required for axial preloads in moment-curvature
runs where pseudoTime must NOT scale the axial force.""" runs where pseudoTime must NOT scale the axial force."""
from otko.core import ConstantTimeSeries from otko.core import ConstantTimeSeries
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project(ndm=2, ndf=3) vm.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0))) vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.apply_command(AddNodalLoadsCommand( vm.apply_command(
vm, {1}, (-180, 0, 0, 0, 0, 0), AddNodalLoadsCommand(
pattern_id=None, new_pattern_name="AxialP", new_ts_type="Constant", 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. # Pattern + TimeSeries both created with the "AxialP" name.
assert vm.project.load_patterns[0].name == "AxialP" assert vm.project.load_patterns[0].name == "AxialP"
ts = vm.project.time_series[0] 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.new_project(ndm=2, ndf=3)
vm.project.nodes.append(Node(id=1, coords=(0, 0, 0))) vm.project.nodes.append(Node(id=1, coords=(0, 0, 0)))
vm.project.time_series.append(LinearTimeSeries(id=1, name="Existing")) vm.project.time_series.append(LinearTimeSeries(id=1, name="Existing"))
vm.project.load_patterns.append(PlainLoadPattern( vm.project.load_patterns.append(
id=1, name="Existing", time_series_id=1, PlainLoadPattern(
)) id=1,
vm.apply_command(AddNodalLoadsCommand( name="Existing",
vm, {1}, (0, 0, 0, 0, 0, 5.0), pattern_id=1, 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) == 1
assert len(vm.project.load_patterns[0].nodal_loads) == 1 assert len(vm.project.load_patterns[0].nodal_loads) == 1

View file

@ -23,7 +23,9 @@ def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-
dlg._mx.setValue(5000.0) dlg._mx.setValue(5000.0)
dlg._my.setValue(5000.0) dlg._my.setValue(5000.0)
dlg._mz.setValue(100.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) assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)

View file

@ -18,15 +18,24 @@ from otko.views.dialogs.assign_zls import ( # noqa: E402
def _project_with_section() -> Project: def _project_with_section() -> Project:
return Project( return Project(
ndm=2, ndf=3, ndm=2,
ndf=3,
nodes=[ nodes=[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0)),
], ],
sections=[ElasticSection( sections=[
id=3, name="MK", E=30000, A=200, Iz=6667, Iy=6667, ElasticSection(
G=12000, J=100, 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 @pytest.mark.gui
def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def] def test_dialog_disabled_when_no_sections(qtbot) -> None: # type: ignore[no-untyped-def]
p = Project( 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))], nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(0, 0, 0))],
) )
dlg = AssignZeroLengthSectionDialog(p, (1, 2)) dlg = AssignZeroLengthSectionDialog(p, (1, 2))

View file

@ -23,7 +23,8 @@ from otko.core import ( # noqa: E402
# ────────────────────── logic helpers (no VTK) ────────────────────── # ────────────────────── logic helpers (no VTK) ──────────────────────
def _nearest_snap( def _nearest_snap(
cx: float, cy: float, cx: float,
cy: float,
world_pts: np.ndarray, world_pts: np.ndarray,
screen_pts: np.ndarray, screen_pts: np.ndarray,
tol_px: float, tol_px: float,
@ -33,7 +34,7 @@ def _nearest_snap(
return None return None
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2 d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
idx = int(np.argmin(d2)) 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 tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
return None 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 """ModelCanvas._grid_intersections_world combines every visible system's
intersections (transformed by that system's origin/rotation).""" intersections (transformed by that system's origin/rotation)."""
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) 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] def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) 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 assert canvas._grid_intersections_world() is None
def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def] def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
"""A system with ``hide_all=True`` must not contribute snap targets.""" """A system with ``hide_all=True`` must not contribute snap targets."""
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) 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] 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.""" """set_hover_snap(pt) creates an actor; passing None removes it."""
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) qtbot.addWidget(canvas)
canvas.show_project(Project( canvas.show_project(
coord_systems=[ Project(
CoordinateGridSystem( coord_systems=[
name="Global", CoordinateGridSystem(
grid=GridSystem( name="Global",
x_grid_lines=make_grid_lines("X", [0.0, 3.0]), grid=GridSystem(
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]), x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
z_grid_lines=make_grid_lines("Z", [0.0]), y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
),
), ),
), ],
], )
)) )
r = canvas._renderer r = canvas._renderer
assert r._hover_actor is None assert r._hover_actor is None
r.set_hover_snap((3.0, 0.0, 0.0)) 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] def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) qtbot.addWidget(canvas)
canvas.show_project(Project( canvas.show_project(
coord_systems=[ Project(
CoordinateGridSystem( coord_systems=[
name="Global", CoordinateGridSystem(
grid=GridSystem( name="Global",
x_grid_lines=make_grid_lines("X", [0.0]), grid=GridSystem(
y_grid_lines=make_grid_lines("Y", [0.0]), x_grid_lines=make_grid_lines("X", [0.0]),
z_grid_lines=make_grid_lines("Z", [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.set_snap_preview_enabled(True)
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0)) canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
assert canvas._renderer._hover_actor is not None 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: 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.""" """Vectorised point-to-segment squared distance, used to verify logic."""
ab = b - a 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) ab_sq = np.where(ab_sq == 0, 1.0, ab_sq)
pa = p - a pa = p - a
t = (pa * ab).sum(axis=1) / ab_sq 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: def test_point_to_segment_endpoint_hit() -> None:
"""A click near an endpoint must also hit — the bug fix this test guards.""" """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]]) a = np.array([[0.0, 50.0]])
b = np.array([[100.0, 50.0]]) b = np.array([[100.0, 50.0]])
d2 = _pt_to_segment_d2(p, a, b) 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: def test_point_to_segment_orthogonal_miss() -> None:
"""Clicking far from a short frame must produce a large distance.""" """Clicking far from a short frame must produce a large distance."""
p = np.array([50.0, 200.0]) p = np.array([50.0, 200.0])
a = np.array([[0.0, 50.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) d2 = _pt_to_segment_d2(p, a, b)
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155 # 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] 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).""" grid bounds so the node remains visible (regression guard)."""
from otko.core import Node from otko.core import Node
from otko.views.canvas3d.model_canvas import ModelCanvas from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas() canvas = ModelCanvas()
qtbot.addWidget(canvas) qtbot.addWidget(canvas)
p = Project( p = Project(

View file

@ -77,17 +77,27 @@ def test_add_nodes_duplicate_id_raises(qtbot) -> None: # type: ignore[no-untype
@pytest.mark.gui @pytest.mark.gui
def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-untyped-def] def test_delete_node_cascades_to_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm() vm = _vm()
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
Node(id=1, coords=(0, 0, 0)), AddNodesCommand(
Node(id=2, coords=(1, 0, 0)), vm,
Node(id=3, coords=(2, 0, 0)), [
])) 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. # Add a material so the element is valid.
vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)) vm.project.materials.append(Steel01(id=1, Fy=420e6, E0=200e9, b=0.01))
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1), AddElementsCommand(
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1), 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. # Delete node 2 → both elements should disappear.
vm.apply_command(DeleteNodesCommand(vm, {2})) vm.apply_command(DeleteNodesCommand(vm, {2}))
assert {n.id for n in vm.project.nodes} == {1, 3} 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 @pytest.mark.gui
def test_set_restraint_round_trip(qtbot) -> None: # type: ignore[no-untyped-def] def test_set_restraint_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm() vm = _vm()
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
Node(id=1, coords=(0, 0, 0)), AddNodesCommand(
Node(id=2, coords=(1, 0, 0)), vm,
])) [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)),
],
)
)
fix = (True, True, True, True, True, True) fix = (True, True, True, True, True, True)
vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix)) vm.apply_command(SetRestraintCommand(vm, {1, 2}, fix))
assert vm.project.node(1).restraint == fix assert vm.project.node(1).restraint == fix

View file

@ -26,32 +26,62 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_setup() -> ProjectViewModel: def _vm_setup() -> ProjectViewModel:
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
Node(id=1, coords=(0, 0, 0)), AddNodesCommand(
Node(id=2, coords=(3, 0, 0)), vm,
])) [
vm.apply_command(AddMaterialsCommand(vm, [ Node(id=1, coords=(0, 0, 0)),
ElasticUniaxial(id=1, name="Steel", E=200e9), Node(id=2, coords=(3, 0, 0)),
])) ],
vm.apply_command(AddSectionsCommand(vm, [ )
ElasticSection( )
id=1, name="Default", E=200e9, A=0.01, vm.apply_command(
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6, 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 return vm
@pytest.mark.gui @pytest.mark.gui
def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-def] def test_frame_to_truss_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup() vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), AddElementsCommand(
])) vm,
vm.apply_command(ConvertElementTypeCommand( [
vm, {1}, "Truss", ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
defaults={"material_id": 1, "area": 0.001}, ],
)) )
)
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"Truss",
defaults={"material_id": 1, "area": 0.001},
)
)
el = vm.project.element(1) el = vm.project.element(1)
assert isinstance(el, TrussElement) assert isinstance(el, TrussElement)
assert el.nodes == (1, 2) 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 @pytest.mark.gui
def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-def] def test_truss_to_frame_conversion(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup() vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), AddElementsCommand(
])) vm,
vm.apply_command(ConvertElementTypeCommand( [
vm, {1}, "ElasticBeamColumn", TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
defaults={"section_id": 1}, ],
)) )
)
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"ElasticBeamColumn",
defaults={"section_id": 1},
)
)
el = vm.project.element(1) el = vm.project.element(1)
assert isinstance(el, ElasticBeamColumn) assert isinstance(el, ElasticBeamColumn)
assert el.nodes == (1, 2) 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 @pytest.mark.gui
def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def] def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup() vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1), AddElementsCommand(
])) vm,
vm.apply_command(ConvertElementTypeCommand( [
vm, {42}, "Truss", defaults={"material_id": 1, "area": 0.002}, 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 isinstance(vm.project.element(42), TrussElement)
assert vm.project.element(42).name == "A" assert vm.project.element(42).name == "A"
vm.undo_stack.undo() vm.undo_stack.undo()
@ -93,13 +142,23 @@ def test_convert_preserves_id_and_is_undoable(qtbot) -> None: # type: ignore[no
@pytest.mark.gui @pytest.mark.gui
def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-def] def test_convert_same_type_is_noop(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup() vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), AddElementsCommand(
])) vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
before = vm.project.element(1) before = vm.project.element(1)
vm.apply_command(ConvertElementTypeCommand( vm.apply_command(
vm, {1}, "Truss", defaults={"material_id": 1, "area": 0.002}, ConvertElementTypeCommand(
)) vm,
{1},
"Truss",
defaults={"material_id": 1, "area": 0.002},
)
)
# Class is still Truss and the element object is unchanged. # Class is still Truss and the element object is unchanged.
assert vm.project.element(1) is before 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 @pytest.mark.gui
def test_unknown_target_type_raises(qtbot) -> None: # type: ignore[no-untyped-def] def test_unknown_target_type_raises(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_setup() vm = _vm_setup()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), AddElementsCommand(
])) vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
],
)
)
with pytest.raises(ValueError): with pytest.raises(ValueError):
vm.apply_command(ConvertElementTypeCommand( vm.apply_command(
vm, {1}, "NotAType", defaults={}, ConvertElementTypeCommand(
)) vm,
{1},
"NotAType",
defaults={},
)
)

View file

@ -27,10 +27,12 @@ def test_linear_time_series_dialog_builds_entity(qtbot) -> None: # type: ignore
@pytest.mark.gui @pytest.mark.gui
def test_plain_pattern_dialog_uses_selected_time_series(qtbot) -> None: # type: ignore[no-untyped-def] def test_plain_pattern_dialog_uses_selected_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
proj = Project(time_series=[ proj = Project(
LinearTimeSeries(id=1, name="GravityTS"), time_series=[
LinearTimeSeries(id=2, name="RampTS"), LinearTimeSeries(id=1, name="GravityTS"),
]) LinearTimeSeries(id=2, name="RampTS"),
]
)
dlg = PlainPatternDialog(project=proj, next_pattern_id=4) dlg = PlainPatternDialog(project=proj, next_pattern_id=4)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)

View file

@ -35,10 +35,15 @@ class _CanvasStub:
def _vm_with_two_nodes() -> ProjectViewModel: def _vm_with_two_nodes() -> ProjectViewModel:
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
Node(id=1, coords=(0, 0, 0)), AddNodesCommand(
Node(id=2, coords=(3, 0, 0)), vm,
])) [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
],
)
)
return vm return vm
@ -86,7 +91,7 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool.activate() tool.activate()
tool.on_node_picked(1) 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 == [] assert vm.project.elements == []
# First-node state preserved so user can finish the gesture. # First-node state preserved so user can finish the gesture.
assert tool._first_node_id == 1 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] 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.""" """Two clicks on empty grid intersections → 2 nodes + 1 frame."""
from otko.core import GridSystem from otko.core import GridSystem
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.project.grid_system = GridSystem( # type: ignore[union-attr] 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(0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 4.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.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr] assert len(vm.project.elements) == 1 # type: ignore[union-attr]
elem = vm.project.elements[0] # type: ignore[union-attr] elem = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(elem, ElasticBeamColumn) assert isinstance(elem, ElasticBeamColumn)
n1 = next(n for n in vm.project.nodes if n.id == elem.nodes[0]) # type: ignore[union-attr] 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] 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] 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.""" """An empty click at an existing node's location must not duplicate it."""
from otko.core import GridSystem 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] 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 = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate() tool.activate()
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,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) 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) assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
elem = vm.project.elements[0] # type: ignore[union-attr] elem = vm.project.elements[0] # type: ignore[union-attr]
assert set(elem.nodes) == {1, 2} 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] 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.""" """First click picks existing node; second click creates new node + frame."""
from otko.core import GridSystem from otko.core import GridSystem
vm = _vm_with_two_nodes() vm = _vm_with_two_nodes()
vm.project.grid_system = GridSystem( # type: ignore[union-attr] 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 = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate() 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 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 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) assert new_node.coords == (6.0, 0.0, 0.0)
elem = vm.project.elements[0] elem = vm.project.elements[0]
assert set(elem.nodes) == {1, new_node.id} assert set(elem.nodes) == {1, new_node.id}

View file

@ -46,8 +46,12 @@ def _vm_with_grid() -> ProjectViewModel:
# ────────────────────────── snap helper ───────────────────────────── # ────────────────────────── snap helper ─────────────────────────────
def test_snap_to_grid_picks_nearest_lines() -> None: def test_snap_to_grid_picks_nearest_lines() -> None:
x, y, z = _snap_to_grid( x, y, z = _snap_to_grid(
2.2, 3.7, 0.6, 2.2,
x_lines=[0.0, 3.0], y_lines=[0.0, 4.0], z_lines=[0.0, 3.0], 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) 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 target. For a grid-less project, the canvas never emits, so the
tool is never invoked. We therefore don't test rejection here. 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 = DrawNodeTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate() tool.activate()
assert vm.project.nodes == [] # type: ignore[union-attr] assert vm.project.nodes == [] # type: ignore[union-attr]
@pytest.mark.gui @pytest.mark.gui

View file

@ -34,10 +34,12 @@ class _CanvasStub:
def _vm_with_grid_and_nodes() -> ProjectViewModel: def _vm_with_grid_and_nodes() -> ProjectViewModel:
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.project.nodes.extend([ vm.project.nodes.extend(
Node(id=1, coords=(0, 0, 0)), [
Node(id=2, coords=(3, 0, 0)), Node(id=1, coords=(0, 0, 0)),
]) Node(id=2, coords=(3, 0, 0)),
]
)
vm.project.coord_systems = [ vm.project.coord_systems = [
CoordinateGridSystem( CoordinateGridSystem(
name="Global", 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(1)
tool.on_node_picked(2) tool.on_node_picked(2)
assert len(vm.project.elements) == 1 # type: ignore[union-attr] assert len(vm.project.elements) == 1 # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr] el = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(el, TrussElement) assert isinstance(el, TrussElement)
assert el.nodes == (1, 2) assert el.nodes == (1, 2)
assert el.area > 0 assert el.area > 0
# A default ElasticUniaxial must have been created for the material. # 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) 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.activate()
tool.on_node_picked(1) tool.on_node_picked(1)
tool.on_node_picked(2) 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 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 @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) tool.on_empty_clicked(3.0, 0.0, 0.0)
# Two nodes created + one truss element. # Two nodes created + one truss element.
assert len(vm.project.nodes) == 2 # 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 len(vm.project.elements) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.elements[0], TrussElement) 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 = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate() tool.activate()
tool.on_node_picked(1) tool.on_node_picked(1)
tool.on_node_picked(1) # same node — must not create a zero-length truss tool.on_node_picked(1) # same node — must not create a zero-length truss
assert vm.project.elements == [] # type: ignore[union-attr] assert vm.project.elements == [] # type: ignore[union-attr]

View file

@ -19,11 +19,18 @@ from otko.core import ( # noqa: E402
@pytest.mark.gui @pytest.mark.gui
def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def] def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog() dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg._x_n.setValue(4); dlg._x_s.setValue(3.0); dlg._x_f.setValue(0.0) dlg._x_n.setValue(4)
dlg._y_n.setValue(2); dlg._y_s.setValue(4.0); dlg._y_f.setValue(0.0) dlg._x_s.setValue(3.0)
dlg._z_n.setValue(3); dlg._z_s.setValue(3.0); dlg._z_f.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() xs, ys, zs = dlg.ordinates()
assert xs == [0.0, 3.0, 6.0, 9.0] assert xs == [0.0, 3.0, 6.0, 9.0]
assert ys == [0.0, 4.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 @pytest.mark.gui
def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def] def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog() dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
dlg._x_n.setValue(0) 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 ( from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog, CoordSystemLocationOrientationDialog,
) )
dlg = CoordSystemLocationOrientationDialog( dlg = CoordSystemLocationOrientationDialog(
origin=(1.5, -2.0, 3.0), origin=(1.5, -2.0, 3.0),
rotation_deg=(0.0, 0.0, 45.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 ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
cs = CoordinateGridSystem( cs = CoordinateGridSystem(
name="Floor2", name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3.5)), 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 ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
cs = CoordinateGridSystem( cs = CoordinateGridSystem(
name="Test", name="Test",
grid=GridSystem(x_grid_lines=make_grid_lines("X", [0.0, 3.0, 6.0, 10.0])), 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 ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = DefineGridSystemDataDialog(is_global=True) dlg = DefineGridSystemDataDialog(is_global=True)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
assert dlg._name_edit.text() == "Global" 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 ( from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog, DefineGridSystemDataDialog,
) )
dlg = DefineGridSystemDataDialog() dlg = DefineGridSystemDataDialog()
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
# Start with empty X table. # Start with empty X table.

View file

@ -20,3 +20,22 @@ def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
window.show() window.show()
assert window.isVisible() assert window.isVisible()
assert window.windowTitle() == "OTKO" 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