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

@ -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)
@ -49,7 +51,8 @@ class DeleteNodesCommand(ProjectCommand):
def redo(self) -> None: def redo(self) -> None:
# Cascade: snapshot every element that references a doomed node. # Cascade: snapshot every element that references a doomed node.
self._removed_elements = [ self._removed_elements = [
(i, el) 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(
update={
"id": next_node_id, "id": next_node_id,
"coords": (orig.coords[0] + k * dx, "coords": (
orig.coords[0] + k * dx,
orig.coords[1] + k * dy, orig.coords[1] + k * dy,
orig.coords[2] + k * dz), orig.coords[2] + k * dz,
}) ),
}
)
self.project.nodes.append(new_node) self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id) self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id mapping[orig.id] = next_node_id
next_node_id += 1 next_node_id += 1
for orig in src_elements: for orig in src_elements:
new_elem = orig.model_copy(update={ new_elem = orig.model_copy(
update={
"id": next_elem_id, "id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes), "nodes": tuple(mapping[nid] for nid in orig.nodes),
}) }
)
self.project.elements.append(new_elem) self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id) self._added_element_ids.add(next_elem_id)
next_elem_id += 1 next_elem_id += 1
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:
self.project.nodes[:] = [ 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(
update={
"id": next_node_id, "id": next_node_id,
"coords": self._reflect(orig.coords, self._plane), "coords": self._reflect(orig.coords, self._plane),
}) }
)
self.project.nodes.append(new_node) self.project.nodes.append(new_node)
self._added_node_ids.add(next_node_id) self._added_node_ids.add(next_node_id)
mapping[orig.id] = next_node_id mapping[orig.id] = next_node_id
next_node_id += 1 next_node_id += 1
for orig in src_elements: for orig in src_elements:
new_elem = orig.model_copy(update={ new_elem = orig.model_copy(
update={
"id": next_elem_id, "id": next_elem_id,
"nodes": tuple(mapping[nid] for nid in orig.nodes), "nodes": tuple(mapping[nid] for nid in orig.nodes),
}) }
)
self.project.elements.append(new_elem) self.project.elements.append(new_elem)
self._added_element_ids.add(next_elem_id) self._added_element_ids.add(next_elem_id)
next_elem_id += 1 next_elem_id += 1
self._notify() self._notify()
def undo(self) -> None: def undo(self) -> None:
self.project.nodes[:] = [ 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

@ -22,5 +22,7 @@ class Entity(BaseModel):
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"
@ -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."
@ -244,17 +245,24 @@ 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}", "id": f"{prefix}{i + 1}",
"ordinate": float(v), "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):

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", "name": "Global",
"coord": {}, "coord": {},
"grid": legacy, "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

@ -52,20 +52,36 @@ class UnitLabels:
_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

@ -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,8 +84,7 @@ 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():
@ -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

@ -51,17 +51,26 @@ class ForceComponent(Enum):
# Map (component, end) → index into the local-force vector for 3D and 2D. # Map (component, end) → index into the local-force vector for 3D and 2D.
_INDEX_3D = { _INDEX_3D = {
(ForceComponent.N, "i"): 0, (ForceComponent.N, "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))

View file

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

@ -52,7 +52,9 @@ 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(
section: object,
project: "Project | None" = None,
) -> tuple[float, float] | None: ) -> tuple[float, float] | None:
"""Return ``(width_y, height_z)`` of the section's local bounding box. """Return ``(width_y, height_z)`` of the section's local bounding box.
@ -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

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

View file

@ -37,7 +37,9 @@ class ModeContribution:
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.
@ -84,7 +86,8 @@ def mass_participation(
omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1]))) omega = float(np.sqrt(abs(modal.eigenvalues[mode_number - 1])))
period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf") period = (2.0 * np.pi / omega) if omega > 0.0 else float("inf")
ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0 ratio = (m_eff / total_mass) if total_mass > 0.0 else 0.0
out.append(ModeContribution( out.append(
ModeContribution(
mode_number=mode_number, mode_number=mode_number,
period=period, period=period,
frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0, frequency=omega / (2.0 * np.pi) if omega > 0.0 else 0.0,
@ -93,7 +96,8 @@ def mass_participation(
effective_mass=m_eff, effective_mass=m_eff,
mass_ratio=ratio, mass_ratio=ratio,
sa_at_period=0.0, # filled in by combine_spectrum sa_at_period=0.0, # filled in by combine_spectrum
)) )
)
return out return out
@ -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)

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,13 +34,20 @@ 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.V3: 3,
ForceComponent.M2: 3,
ForceComponent.T: 2, 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}
@ -111,10 +120,14 @@ class DiagramRenderer:
width = abs(v_j) * 0.5 width = abs(v_j) * 0.5
if width == 0.0: if width == 0.0:
continue continue
quad = np.vstack([ quad = np.vstack(
pi - perp * width, pj - perp * width, [
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,7 +217,8 @@ 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,

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

@ -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__(
self,
next_node_id: int,
grid: GridSystem, grid: GridSystem,
ndm: int = 3, ndm: int = 3,
parent: QWidget | None = None, parent: QWidget | None = None,
*, *,
plane_elevation: float | None = None) -> 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(
QLabel(
f"<i>Suggested Z = active working-plane level " f"<i>Suggested Z = active working-plane level "
f"({self._plane_elevation:g}).</i>", f"({self._plane_elevation:g}).</i>",
)) )
)
root.addLayout(form) root.addLayout(form)
self._snap_cb = QCheckBox("Snap to nearest grid intersection") self._snap_cb = QCheckBox("Snap to nearest grid intersection")
self._snap_cb.setChecked(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(
QLabel(
"Tie selected nodes together in chosen DOFs using OpenSees " "Tie selected nodes together in chosen DOFs using OpenSees "
"<code>equalDOF</code>.", "<code>equalDOF</code>.",
)) )
)
form = QFormLayout() form = QFormLayout()
@ -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,7 +158,8 @@ class AssignHingeDialog(QDialog):
if el is None: if el is None:
return None return None
if getattr(el, "type", None) == "BeamWithHinges": if getattr(el, "type", None) == "BeamWithHinges":
collected.append({ collected.append(
{
"section_i_id": getattr(el, "section_i_id", None), "section_i_id": getattr(el, "section_i_id", None),
"section_j_id": getattr(el, "section_j_id", None), "section_j_id": getattr(el, "section_j_id", None),
"lp_i": getattr(el, "lp_i", None), "lp_i": getattr(el, "lp_i", None),
@ -171,7 +170,8 @@ class AssignHingeDialog(QDialog):
"Iy": getattr(el, "Iy", None), "Iy": getattr(el, "Iy", None),
"G": getattr(el, "G", None), "G": getattr(el, "G", None),
"J": getattr(el, "J", None), "J": getattr(el, "J", None),
}) }
)
continue continue
sec_id = getattr(el, "section_id", None) sec_id = getattr(el, "section_id", None)
if sec_id is None: if sec_id is None:
@ -185,7 +185,8 @@ class AssignHingeDialog(QDialog):
# prefill the hinge pickers with the section itself. # prefill the hinge pickers with the section itself.
collected.append({"section_i_id": sec_id, "section_j_id": sec_id}) collected.append({"section_i_id": sec_id, "section_j_id": sec_id})
continue continue
collected.append({ collected.append(
{
"section_i_id": sec_id, "section_i_id": sec_id,
"section_j_id": sec_id, "section_j_id": sec_id,
"E": getattr(sec, "E", None), "E": getattr(sec, "E", None),
@ -194,12 +195,12 @@ class AssignHingeDialog(QDialog):
"Iy": getattr(sec, "Iy", None), "Iy": getattr(sec, "Iy", None),
"G": getattr(sec, "G", None), "G": getattr(sec, "G", None),
"J": getattr(sec, "J", None), "J": getattr(sec, "J", None),
}) }
)
if not collected: if not collected:
return None return None
merged: dict = {} merged: dict = {}
for key in ("section_i_id", "section_j_id", "lp_i", "lp_j", 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,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(
QLabel(
f"Connect node <b>{self._node_ids[0]}</b> and " f"Connect node <b>{self._node_ids[0]}</b> and "
f"<b>{self._node_ids[1]}</b> through a zero-length " f"<b>{self._node_ids[1]}</b> through a zero-length "
"section element. The two nodes must share the same " "section element. The two nodes must share the same "
"coordinates." "coordinates."
)) )
)
form = QFormLayout() form = QFormLayout()
self._section_cb = QComboBox() self._section_cb = QComboBox()
@ -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(
QLabel(
"<i>Run these Static cases first, then hold them constant via " "<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the transient starts.</i>" "loadConst -time 0.0 before the transient starts.</i>"
)) )
)
self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>")) self._layout.addRow(QLabel("<b>Patterns to remove after preload (optional):</b>"))
self._layout.addRow(self._remove_patterns_picker) self._layout.addRow(self._remove_patterns_picker)
self._layout.addRow("dt:", self._dt) self._layout.addRow("dt:", self._dt)
@ -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(
QLabel(
"<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 " "<i>If mode-1 damping is > 0, the runner computes βK = 2ζ/√λ1 "
"after preload and uses it instead of the manual βK value.</i>" "after preload and uses it instead of the manual βK value.</i>"
)) )
)
def _populate_specific(self, c: TransientCase) -> None: def _populate_specific(self, c: TransientCase) -> None:
_select_pattern_ids(self._patterns_picker, c.pattern_ids) _select_pattern_ids(self._patterns_picker, c.pattern_ids)
_set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {}) _set_factor_spins(self._pattern_factor_spins, getattr(c, "pattern_factors", {}) or {})
_select_case_ids(self._preload_picker, c.preload_case_ids) _select_case_ids(self._preload_picker, c.preload_case_ids)
_select_pattern_ids(self._remove_patterns_picker, c.remove_patterns) _select_pattern_ids(self._remove_patterns_picker, c.remove_patterns)
self._dt.setValue(c.dt); self._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(
QLabel(
"<i>Run these Static cases first, then hold them constant via " "<i>Run these Static cases first, then hold them constant via "
"loadConst -time 0.0 before the pushover starts.</i>" "loadConst -time 0.0 before the pushover starts.</i>"
)) )
)
self._layout.addRow("Control node:", self._control_node) self._layout.addRow("Control node:", self._control_node)
self._layout.addRow("Control DOF:", self._control_dof) self._layout.addRow("Control DOF:", self._control_dof)
self._layout.addRow("Target displacement:", self._target) self._layout.addRow("Target displacement:", self._target)
@ -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(
QLabel(
"<i>Damping is used by CQC modal correlation only; " "<i>Damping is used by CQC modal correlation only; "
"leave at 0 to use the spectrum's own damping ratio.</i>", "leave at 0 to use the spectrum's own damping ratio.</i>",
)) )
)
def _populate_specific(self, c: ResponseSpectrumCase) -> None: def _populate_specific(self, c: ResponseSpectrumCase) -> None:
self._modal_case.setValue(c.modal_case_id) self._modal_case.setValue(c.modal_case_id)
@ -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(
update={
"name": self._unique_name(new_sys.name), "name": self._unique_name(new_sys.name),
}) }
)
self._systems.append(new_sys) self._systems.append(new_sys)
self._refresh_list(select_name=new_sys.name) self._refresh_list(select_name=new_sys.name)
@ -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(
update={
"name": self._unique_name(updated.name), "name": self._unique_name(updated.name),
}) }
)
row = self._list.currentRow() row = self._list.currentRow()
self._systems[row] = updated self._systems[row] = updated
self._refresh_list(select_name=updated.name) self._refresh_list(select_name=updated.name)
@ -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

@ -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(
GridLine(
id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"), id=(id_item.text().strip() if id_item else f"{self.axis}{row + 1}"),
ordinate=ords[row] if row < len(ords) else 0.0, ordinate=ords[row] if row < len(ords) else 0.0,
line_type=( line_type=(
line_type_widget.currentText() # type: ignore[union-attr] line_type_widget.currentText() # type: ignore[union-attr]
if isinstance(line_type_widget, QComboBox) else "Primary" if isinstance(line_type_widget, QComboBox)
else "Primary"
), ),
visible=( visible=(
visible_widget.isChecked() # type: ignore[union-attr] visible_widget.isChecked() # type: ignore[union-attr]
if isinstance(visible_widget, QCheckBox) else True if isinstance(visible_widget, QCheckBox)
else True
), ),
bubble_loc=( bubble_loc=(
bubble_widget.currentText() # type: ignore[union-attr] bubble_widget.currentText() # type: ignore[union-attr]
if isinstance(bubble_widget, QComboBox) else "End" if isinstance(bubble_widget, QComboBox)
else "End"
), ),
color=(color_item.text() if color_item else "#808080"), color=(color_item.text() if color_item else "#808080"),
)) )
)
return out return out
# ── row ops ─────────────────────────────────────────────────── # ── row ops ───────────────────────────────────────────────────
@ -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
@ -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["wy"].value(),
self._spinboxes["wz"].value(), self._spinboxes["wz"].value(),
self._spinboxes["wx"].value()) self._spinboxes["wx"].value(),
)
def selected_pattern_id(self) -> int | None: def selected_pattern_id(self) -> int | None:
"""Return the chosen pattern id, or ``None`` if the user picked """Return the chosen pattern id, or ``None`` if the user picked

View file

@ -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(
QLabel(
"Enter the system's origin and orientation relative to <b>Global</b>. " "Enter the system's origin and orientation relative to <b>Global</b>. "
"Rotations are XYZ Euler angles in degrees." "Rotations are XYZ Euler angles in degrees."
)) )
)
origin_box = QGroupBox("Origin (world units)") origin_box = QGroupBox("Origin (world units)")
of = QFormLayout(origin_box) of = QFormLayout(origin_box)
self._ox = self._spin(); 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),
("ε_c0 (-):", self._epsc0),
("f'cu (-):", self._fpcu),
("ε_U (-):", self._epsU),
("λ (unload ratio):", self._lambda), ("λ (unload ratio):", self._lambda),
("ft (tensile):", self._ft), ("Ets (soften):", self._ets)): ("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(
QLabel(
"<b>Path TimeSeries</b> — tabulated values sampled at a " "<b>Path TimeSeries</b> — tabulated values sampled at a "
"uniform time step. Used by UniformExcitation (ground " "uniform time step. Used by UniformExcitation (ground "
"motion) and by PlainLoadPattern scaled forces." "motion) and by PlainLoadPattern scaled forces."
)) )
)
form = QFormLayout() form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion") self._name_edit = QLineEdit("GroundMotion")
@ -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(
QLabel(
"<b>Quick-define a regular (Cartesian) grid.</b><br>" "<b>Quick-define a regular (Cartesian) grid.</b><br>"
"Each axis: number of lines, equal spacing, first line coordinate." "Each axis: number of lines, equal spacing, first line coordinate."
)) )
)
self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root) self._x_n, self._x_s, self._x_f = self._axis_group("X Grid Data", root)
self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root) self._y_n, self._y_s, self._y_f = self._axis_group("Y Grid Data", root)
self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root) self._z_n, self._z_s, self._z_f = self._axis_group("Z Grid Data", root)
# Sensible defaults. # Sensible defaults.
self._x_n.setValue(3); self._x_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(
QLabel(
"<i>Values are applied for this run only via a copy of the case — " "<i>Values are applied for this run only via a copy of the case — "
"the stored case is not modified. If mode-1 damping is " "the stored case is not modified. If mode-1 damping is "
"greater than zero, the runner computes betaK from the first " "greater than zero, the runner computes betaK from the first "
"mode after preload and overrides the manual betaK value.</i>", "mode after preload and overrides the manual betaK value.</i>",
)) )
)
layout.addWidget(self._damping_box) layout.addWidget(self._damping_box)
self._progress = QProgressBar() self._progress = QProgressBar()
@ -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(
update={
"rayleigh_alpha_m": float(self._alpha_m.value()), "rayleigh_alpha_m": float(self._alpha_m.value()),
"rayleigh_beta_k": float(self._beta_k.value()), "rayleigh_beta_k": float(self._beta_k.value()),
"rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None, "rayleigh_mode1_damping": mode1 if mode1 > 0.0 else None,
}) }
)
self._results = None self._results = None
self._log.clear() self._log.clear()
results_dir: Path | None = None results_dir: Path | None = None
@ -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,17 +45,26 @@ 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__(
self,
material_ids: list[int],
existing: FiberSection | None = None, existing: FiberSection | None = None,
parent: QWidget | None = 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)
@ -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(
QListWidgetItem(
f"Rect patch mat={mid} " f"Rect patch mat={mid} "
f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) " f"({p.y_i:.3f},{p.z_i:.3f})→({p.y_j:.3f},{p.z_j:.3f}) "
f"{p.n_fib_y}×{p.n_fib_z}", f"{p.n_fib_y}×{p.n_fib_z}",
)) )
)
else: else:
p = CircularPatch( p = CircularPatch(
material_id=mid, material_id=mid,
n_fib_circ=self._circ_nc.value(), n_fib_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(
QListWidgetItem(
f"Circ patch mat={mid} " f"Circ patch mat={mid} "
f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}", f"r={p.r_inner:.3f}→{p.r_outer:.3f} {p.n_fib_circ}×{p.n_fib_rad}",
)) )
)
self._refresh_preview() self._refresh_preview()
def _on_add_layer(self) -> None: def _on_add_layer(self) -> None:
@ -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(
QListWidgetItem(
f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}", f"Layer mat={mid} {lay.n_bars} bars A={lay.bar_area:.4g}",
)) )
)
self._refresh_preview() self._refresh_preview()
def _on_remove(self) -> None: def _on_remove(self) -> None:
@ -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(
QListWidgetItem(
f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}", f"Rect patch mat={p.material_id} {p.n_fib_y}×{p.n_fib_z}",
)) )
)
elif isinstance(p, CircularPatch): elif isinstance(p, CircularPatch):
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
QListWidgetItem(
f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}", f"Circ patch mat={p.material_id} {p.n_fib_circ}×{p.n_fib_rad}",
)) )
)
for lay in sec.layers: for lay in sec.layers:
if isinstance(lay, StraightLayer): if isinstance(lay, StraightLayer):
self._layers.append(lay) self._layers.append(lay)
self._item_list.addItem(QListWidgetItem( self._item_list.addItem(
QListWidgetItem(
f"Layer mat={lay.material_id} {lay.n_bars} bars", 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(
QLabel(
"<i>Edit this fiber section from the Section Library list — " "<i>Edit this fiber section from the Section Library list — "
"Add / Modify uses the visual Fiber Section Editor.</i>" "Add / Modify uses the visual Fiber Section Editor.</i>"
)) )
)
self._cached: FiberSection | None = None self._cached: FiberSection | None = None
def _populate_specific(self, s: FiberSection) -> None: def _populate_specific(self, s: FiberSection) -> None:
@ -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(
QLabel(
"<b>UniformExcitation</b> — apply a base ground motion " "<b>UniformExcitation</b> — apply a base ground motion "
"to every free node in the chosen DOF direction." "to every free node in the chosen DOF direction."
)) )
)
form = QFormLayout() form = QFormLayout()
self._name_edit = QLineEdit("GroundMotion") self._name_edit = QLineEdit("GroundMotion")
@ -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()

View file

@ -35,8 +35,7 @@ class ForceDiagramView(QWidget):
changed = Signal(object, float) # (ForceComponent, scale) changed = Signal(object, float) # (ForceComponent, scale)
closed = Signal() closed = Signal()
def __init__(self, suggested_scale: float = 1.0, 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

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(
"X, Y, Z:",
QLabel(
f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}", f"{node.coords[0]:.4f}, {node.coords[1]:.4f}, {node.coords[2]:.4f}",
)) ),
)
form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint))) form.addRow("Restraint:", QLabel(self._fmt_restraint(node.restraint)))
self._layout.addLayout(form) self._layout.addLayout(form)
@ -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,14 +197,17 @@ 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 (
hasattr(el, "section_id")
and self._project is not None and self._project is not None
and self._project.sections and self._project.sections
and self.on_change_element_section is not None): and self.on_change_element_section is not None
):
sec_cb = QComboBox() sec_cb = QComboBox()
for s in self._project.sections: for s in self._project.sections:
sec_cb.addItem( sec_cb.addItem(
f"#{s.id} {s.name or s.type}", 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:
@ -205,16 +217,19 @@ 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 (
hasattr(el, "material_id")
and self._project is not None and self._project is not None
and self._project.materials and self._project.materials
and self.on_change_element_material is not None): and self.on_change_element_material is not None
):
mat_cb = QComboBox() mat_cb = QComboBox()
for m in self._project.materials: for m in self._project.materials:
mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id) mat_cb.addItem(f"#{m.id} {m.name or m.type}", m.id)
@ -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"):
@ -237,8 +253,11 @@ class PropertyEditorDock(QScrollArea):
area_spin.setRange(1e-12, 1e6) area_spin.setRange(1e-12, 1e6)
area_spin.setDecimals(8) area_spin.setDecimals(8)
area_spin.setValue(float(el.area)) # type: ignore[attr-defined] area_spin.setValue(float(el.area)) # type: ignore[attr-defined]
area_spin.setSingleStep(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(
QLabel(
f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}", f"<b>{len(node_ids)}</b> node(s) selected: {self._fmt_id_list(node_ids)}",
)) )
)
if element_ids: if element_ids:
self._layout.addWidget(QLabel( self._layout.addWidget(
QLabel(
f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}", f"<b>{len(element_ids)}</b> element(s) selected: {self._fmt_id_list(element_ids)}",
)) )
)
self._layout.addStretch(1) self._layout.addStretch(1)
@staticmethod @staticmethod
@ -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(
QLabel(
f"<b>Steps:</b> {r.n_steps}<br>" f"<b>Steps:</b> {r.n_steps}<br>"
f"<b>dt:</b> {r.dt:g}<br>" f"<b>dt:</b> {r.dt:g}<br>"
f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>" f"<b>Total time:</b> {r.n_steps * r.dt:g}<br>"
f"<b>HDF5 file:</b> <code>{r.h5_path}</code>" f"<b>HDF5 file:</b> <code>{r.h5_path}</code>"
)) )
layout.addWidget(QLabel( )
"<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

@ -86,8 +86,9 @@ class ToolController(QObject):
toolChanged = Signal(object) # emits the new CanvasTool (or None for default) toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
def __init__(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,12 +76,13 @@ 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[0] - world[0]) ** 2
+ (candidate[1] - world[1]) ** 2 + (candidate[1] - world[1]) ** 2
+ (candidate[2] - world[2]) ** 2) + (candidate[2] - world[2]) ** 2
)
if d2 < best_d2: if d2 < best_d2:
best = candidate best = candidate
best_d2 = d2 best_d2 = d2
@ -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(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]), 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(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, name="A", pattern_ids=[1], n_steps=1), 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(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]), 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(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, pattern_ids=[1]), StaticCase(id=1, pattern_ids=[1]),
ModalCase(id=2), 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=1, coords=(0.0, 0.0, 0.0), restraint=(True, True, False, False, False, True)
),
Node(id=2, coords=(L, 0.0, 0.0)), Node(id=2, coords=(L, 0.0, 0.0)),
])) ],
vm.apply_command(AddSectionsCommand(vm, [ )
)
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6), ElasticSection(id=1, E=200e9, A=0.01, Iz=8.333e-6),
])) ],
vm.apply_command(AddElementsCommand(vm, [ )
)
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), 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(
AddAnalysisCasesCommand(
vm,
[
StaticCase(id=1, name="Cantilever", pattern_ids=[1]), StaticCase(id=1, name="Cantilever", pattern_ids=[1]),
])) ],
)
)
return vm return vm

View file

@ -48,7 +48,8 @@ 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…>"
@ -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,
@ -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,6 +103,7 @@ 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
@ -110,6 +113,7 @@ def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # t
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,9 +138,11 @@ 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(
Project(
coord_systems=[ coord_systems=[
CoordinateGridSystem( CoordinateGridSystem(
name="Global", name="Global",
@ -147,7 +153,8 @@ def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untype
), ),
), ),
], ],
)) )
)
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,9 +165,11 @@ 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(
Project(
coord_systems=[ coord_systems=[
CoordinateGridSystem( CoordinateGridSystem(
name="Global", name="Global",
@ -171,7 +180,8 @@ def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-unty
), ),
), ),
], ],
)) )
)
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
@ -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(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)), Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 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(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1), TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), 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(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 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(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)), Node(id=2, coords=(3, 0, 0)),
])) ],
vm.apply_command(AddMaterialsCommand(vm, [ )
)
vm.apply_command(
AddMaterialsCommand(
vm,
[
ElasticUniaxial(id=1, name="Steel", E=200e9), ElasticUniaxial(id=1, name="Steel", E=200e9),
])) ],
vm.apply_command(AddSectionsCommand(vm, [ )
)
vm.apply_command(
AddSectionsCommand(
vm,
[
ElasticSection( ElasticSection(
id=1, name="Default", E=200e9, A=0.01, id=1,
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6, 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(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
])) ],
vm.apply_command(ConvertElementTypeCommand( )
vm, {1}, "Truss", )
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"Truss",
defaults={"material_id": 1, "area": 0.001}, 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(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1),
])) ],
vm.apply_command(ConvertElementTypeCommand( )
vm, {1}, "ElasticBeamColumn", )
vm.apply_command(
ConvertElementTypeCommand(
vm,
{1},
"ElasticBeamColumn",
defaults={"section_id": 1}, 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(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1), ElasticBeamColumn(id=42, name="A", nodes=(1, 2), section_id=1),
])) ],
vm.apply_command(ConvertElementTypeCommand( )
vm, {42}, "Truss", defaults={"material_id": 1, "area": 0.002}, )
)) vm.apply_command(
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(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), 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(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=0.001, material_id=1), 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(
time_series=[
LinearTimeSeries(id=1, name="GravityTS"), LinearTimeSeries(id=1, name="GravityTS"),
LinearTimeSeries(id=2, name="RampTS"), 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(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)), Node(id=2, coords=(3, 0, 0)),
])) ],
)
)
return vm return vm
@ -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]
@ -179,9 +185,12 @@ 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()
@ -198,9 +207,12 @@ 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()

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,7 +103,8 @@ 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]

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=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)), Node(id=2, coords=(3, 0, 0)),
]) ]
)
vm.project.coord_systems = [ vm.project.coord_systems = [
CoordinateGridSystem( CoordinateGridSystem(
name="Global", name="Global",

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

View file

@ -77,9 +77,14 @@ def test_concrete04_read_with_tension(qtbot) -> None: # type: ignore[no-untyped
def test_concrete04_populate_round_trip(qtbot) -> None: # type: ignore[no-untyped-def] def test_concrete04_populate_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
"""populate(mat) + read() reproduces the original model object.""" """populate(mat) + read() reproduces the original model object."""
original = Concrete04( original = Concrete04(
id=3, name="C40-Tension", id=3,
fpc=-40e6, epsc0=-0.0022, epscu=-0.006, Ec=32e9, name="C40-Tension",
fct=2.5e6, et=8e-5, fpc=-40e6,
epsc0=-0.0022,
epscu=-0.006,
Ec=32e9,
fct=2.5e6,
et=8e-5,
) )
form = Concrete04Form() form = Concrete04Form()

View file

@ -83,6 +83,7 @@ def test_spinbox_uses_c_locale_for_decimal_separator(qtbot) -> None: # type: ig
the OS locale. On tr_TR / de_DE Windows the default QLocale expects the OS locale. On tr_TR / de_DE Windows the default QLocale expects
',' and silently rejects Tcl-style '-0.004' inputs.""" ',' and silently rejects Tcl-style '-0.004' inputs."""
from PySide6.QtCore import QLocale from PySide6.QtCore import QLocale
form = Concrete01Form() form = Concrete01Form()
qtbot.addWidget(form) qtbot.addWidget(form)
assert form._epsc0.locale().decimalPoint() == "." assert form._epsc0.locale().decimalPoint() == "."

View file

@ -39,9 +39,14 @@ def test_add_time_series_command_is_undoable(qtbot) -> None: # type: ignore[no-
def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no-untyped-def] def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr] vm.project.time_series.append(
id=1, name="Existing", dt=0.01, values=[0.0], PathTimeSeries( # type: ignore[union-attr]
)) id=1,
name="Existing",
dt=0.01,
values=[0.0],
)
)
ts2 = PathTimeSeries(id=1, name="Duplicate", dt=0.01, values=[0.0]) ts2 = PathTimeSeries(id=1, name="Duplicate", dt=0.01, values=[0.0])
with pytest.raises(ValueError): with pytest.raises(ValueError):
vm.apply_command(AddTimeSeriesCommand(vm, ts2)) vm.apply_command(AddTimeSeriesCommand(vm, ts2))
@ -51,11 +56,19 @@ def test_add_time_series_rejects_duplicate_id(qtbot) -> None: # type: ignore[no
def test_add_load_pattern_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def] def test_add_load_pattern_command_is_undoable(qtbot) -> None: # type: ignore[no-untyped-def]
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.project.time_series.append(PathTimeSeries( # type: ignore[union-attr] vm.project.time_series.append(
id=1, name="GM", dt=0.01, values=[0.1, 0.2], PathTimeSeries( # type: ignore[union-attr]
)) id=1,
name="GM",
dt=0.01,
values=[0.1, 0.2],
)
)
pat = UniformExcitationPattern( pat = UniformExcitationPattern(
id=1, name="EQ", direction=1, accel_series_id=1, id=1,
name="EQ",
direction=1,
accel_series_id=1,
) )
vm.apply_command(AddLoadPatternCommand(vm, pat)) vm.apply_command(AddLoadPatternCommand(vm, pat))
assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr] assert len(vm.project.load_patterns) == 1 # type: ignore[union-attr]
@ -67,6 +80,7 @@ def test_add_load_pattern_command_is_undoable(qtbot) -> None: # type: ignore[no
@pytest.mark.gui @pytest.mark.gui
def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def] def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
dlg = PathTimeSeriesDialog(next_ts_id=1) dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
# Simulate a plain-values import by directly seeding the values # Simulate a plain-values import by directly seeding the values
@ -87,20 +101,24 @@ def test_path_ts_dialog_manual_entry(qtbot, tmp_path) -> None: # type: ignore[n
def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def] def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[no-untyped-def]
"""_on_import_peer populates dt / npts from the header + value list.""" """_on_import_peer populates dt / npts from the header + value list."""
from otko.views.dialogs.path_time_series import PathTimeSeriesDialog from otko.views.dialogs.path_time_series import PathTimeSeriesDialog
rec = tmp_path / "test.at2" rec = tmp_path / "test.at2"
rec.write_text(textwrap.dedent("""\ rec.write_text(
textwrap.dedent("""\
PEER PACIFIC PEER PACIFIC
EL CENTRO 1940 EL CENTRO 1940
ACCELERATION IN G ACCELERATION IN G
3 0.025 NPTS, DT 3 0.025 NPTS, DT
0.01 -0.02 0.03 0.01 -0.02 0.03
""")) """)
)
dlg = PathTimeSeriesDialog(next_ts_id=1) dlg = PathTimeSeriesDialog(next_ts_id=1)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
# Feed the file path directly through the parser + state-setter # Feed the file path directly through the parser + state-setter
# logic. Simulating QFileDialog in a headless test is fragile; # logic. Simulating QFileDialog in a headless test is fragile;
# calling _on_import_peer's internals is the stable path. # calling _on_import_peer's internals is the stable path.
from otko.services.peer_record import parse_peer_record from otko.services.peer_record import parse_peer_record
dt, npts, vals = parse_peer_record(rec) dt, npts, vals = parse_peer_record(rec)
dlg._values = vals dlg._values = vals
dlg._dt_spin.setValue(dt) dlg._dt_spin.setValue(dt)
@ -114,6 +132,7 @@ def test_path_ts_dialog_imports_peer(qtbot, tmp_path) -> None: # type: ignore[n
def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type: ignore[no-untyped-def] def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type: ignore[no-untyped-def]
"""With no TimeSeries defined, the dialog's picker is disabled.""" """With no TimeSeries defined, the dialog's picker is disabled."""
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
proj = Project() proj = Project()
dlg = UniformExcitationDialog(project=proj, next_pattern_id=1) dlg = UniformExcitationDialog(project=proj, next_pattern_id=1)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
@ -123,9 +142,12 @@ def test_uniform_excitation_dialog_requires_time_series(qtbot) -> None: # type:
@pytest.mark.gui @pytest.mark.gui
def test_uniform_excitation_dialog_builds_pattern(qtbot) -> None: # type: ignore[no-untyped-def] def test_uniform_excitation_dialog_builds_pattern(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.uniform_excitation import UniformExcitationDialog from otko.views.dialogs.uniform_excitation import UniformExcitationDialog
proj = Project(time_series=[
proj = Project(
time_series=[
PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]), PathTimeSeries(id=7, name="GM", dt=0.01, values=[0.0, 0.1]),
]) ]
)
dlg = UniformExcitationDialog(project=proj, next_pattern_id=2) dlg = UniformExcitationDialog(project=proj, next_pattern_id=2)
qtbot.addWidget(dlg) qtbot.addWidget(dlg)
# Pick direction = 1 (X) — already default. # Pick direction = 1 (X) — already default.

View file

@ -30,18 +30,34 @@ from otko.viewmodels import ProjectViewModel # noqa: E402
def _vm_with_steel() -> ProjectViewModel: def _vm_with_steel() -> ProjectViewModel:
vm = ProjectViewModel() vm = ProjectViewModel()
vm.new_project() vm.new_project()
vm.apply_command(AddNodesCommand(vm, [ vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(1, 0, 0)), Node(id=2, coords=(1, 0, 0)),
Node(id=3, coords=(2, 0, 0)), Node(id=3, coords=(2, 0, 0)),
])) ],
vm.apply_command(AddMaterialsCommand(vm, [ )
)
vm.apply_command(
AddMaterialsCommand(
vm,
[
Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01), Steel01(id=1, name="S420", Fy=420e6, E0=200e9, b=0.01),
])) ],
vm.apply_command(AddSectionsCommand(vm, [ )
ElasticSection(id=1, name="Default", E=200e9, A=0.01, )
Iz=8.33e-6, Iy=8.33e-6, G=80e9, J=1e-6), vm.apply_command(
])) 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
@ -91,8 +107,7 @@ def test_update_unknown_id_raises(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui @pytest.mark.gui
def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyped-def] def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel() vm = _vm_with_steel()
new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02, new = ElasticSection(id=1, name="Updated", E=200e9, A=0.02, Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
Iz=2e-5, Iy=2e-5, G=80e9, J=2e-6)
vm.apply_command(UpdateSectionCommand(vm, new)) vm.apply_command(UpdateSectionCommand(vm, new))
s = vm.project.section(1) s = vm.project.section(1)
assert s.A == 0.02 assert s.A == 0.02
@ -106,14 +121,23 @@ def test_update_section_changes_inertia(qtbot) -> None: # type: ignore[no-untyp
def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def] def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel() vm = _vm_with_steel()
# Add a second section and two frames. # Add a second section and two frames.
vm.apply_command(AddSectionsCommand(vm, [ vm.apply_command(
ElasticSection(id=2, name="Big", E=200e9, A=0.05, AddSectionsCommand(
Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6), vm,
])) [
vm.apply_command(AddElementsCommand(vm, [ ElasticSection(id=2, name="Big", E=200e9, A=0.05, Iz=4e-5, Iy=4e-5, G=80e9, J=2e-6),
],
)
)
vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1), ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
])) ],
)
)
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=2)) vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=2))
assert vm.project.element(1).section_id == 2 assert vm.project.element(1).section_id == 2
assert vm.project.element(2).section_id == 2 assert vm.project.element(2).section_id == 2
@ -126,10 +150,15 @@ def test_assign_section_to_frames(qtbot) -> None: # type: ignore[no-untyped-def
def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-untyped-def] def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-untyped-def]
"""Truss has no section_id — command should silently skip it.""" """Truss has no section_id — command should silently skip it."""
vm = _vm_with_steel() vm = _vm_with_steel()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1), TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1), ElasticBeamColumn(id=2, nodes=(2, 3), section_id=1),
])) ],
)
)
vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=1)) vm.apply_command(AssignSectionCommand(vm, {1, 2}, section_id=1))
# Frame ok, truss unchanged (still material-based). # Frame ok, truss unchanged (still material-based).
assert vm.project.element(2).section_id == 1 assert vm.project.element(2).section_id == 1
@ -140,13 +169,23 @@ def test_assign_section_skips_truss_elements(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui @pytest.mark.gui
def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def] def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel() vm = _vm_with_steel()
vm.apply_command(AddMaterialsCommand(vm, [ vm.apply_command(
AddMaterialsCommand(
vm,
[
Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005), Steel02(id=2, name="S355", Fy=355e6, E0=210e9, b=0.005),
])) ],
vm.apply_command(AddElementsCommand(vm, [ )
)
vm.apply_command(
AddElementsCommand(
vm,
[
TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1), TrussElement(id=1, nodes=(1, 2), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1), TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
])) ],
)
)
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=2)) vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=2))
assert vm.project.element(1).material_id == 2 assert vm.project.element(1).material_id == 2
assert vm.project.element(2).material_id == 2 assert vm.project.element(2).material_id == 2
@ -157,10 +196,15 @@ def test_assign_material_to_truss(qtbot) -> None: # type: ignore[no-untyped-def
@pytest.mark.gui @pytest.mark.gui
def test_assign_material_skips_frame_elements(qtbot) -> None: # type: ignore[no-untyped-def] def test_assign_material_skips_frame_elements(qtbot) -> None: # type: ignore[no-untyped-def]
vm = _vm_with_steel() vm = _vm_with_steel()
vm.apply_command(AddElementsCommand(vm, [ vm.apply_command(
AddElementsCommand(
vm,
[
ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1), ElasticBeamColumn(id=1, nodes=(1, 2), section_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1), TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
])) ],
)
)
vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=1)) vm.apply_command(AssignMaterialCommand(vm, {1, 2}, material_id=1))
# Frame doesn't have material_id; only the truss is affected. # Frame doesn't have material_id; only the truss is affected.
assert vm.project.element(2).material_id == 1 assert vm.project.element(2).material_id == 1

View file

@ -37,8 +37,7 @@ def test_steel01_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
def test_steel02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def] def test_steel02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.material_forms import Steel02Form from otko.views.dialogs.material_forms import Steel02Form
original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005, original = Steel02(id=3, name="S355", Fy=355e6, E0=210e9, b=0.005, R0=18.0, cR1=0.925, cR2=0.15)
R0=18.0, cR1=0.925, cR2=0.15)
form = Steel02Form() form = Steel02Form()
form.populate(original) form.populate(original)
restored = form.read() restored = form.read()
@ -50,8 +49,15 @@ def test_concrete02_form_round_trip(qtbot) -> None: # type: ignore[no-untyped-d
from otko.views.dialogs.material_forms import Concrete02Form from otko.views.dialogs.material_forms import Concrete02Form
original = Concrete02( original = Concrete02(
id=2, name="C30", fpc=-30e6, epsc0=-0.002, fpcu=-15e6, epsU=-0.005, id=2,
ft=3e6, Ets=2e9, **{"lambda": 0.1}, name="C30",
fpc=-30e6,
epsc0=-0.002,
fpcu=-15e6,
epsU=-0.005,
ft=3e6,
Ets=2e9,
**{"lambda": 0.1},
) )
form = Concrete02Form() form = Concrete02Form()
form.populate(original) form.populate(original)
@ -75,8 +81,14 @@ def test_elastic_section_form_round_trip(qtbot) -> None: # type: ignore[no-unty
from otko.views.dialogs.section_forms import ElasticSectionForm from otko.views.dialogs.section_forms import ElasticSectionForm
original = ElasticSection( original = ElasticSection(
id=11, name="W14x90", id=11,
E=200e9, A=0.017, Iz=4.16e-4, Iy=1.29e-4, G=80e9, J=2.04e-6, name="W14x90",
E=200e9,
A=0.017,
Iz=4.16e-4,
Iy=1.29e-4,
G=80e9,
J=2.04e-6,
) )
form = ElasticSectionForm() form = ElasticSectionForm()
form.populate(original) form.populate(original)

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