chore/adopt-local-tree #4

Merged
smill merged 13 commits from chore/adopt-local-tree into main 2026-09-16 15:48:16 -04:00
152 changed files with 3394 additions and 1651 deletions
Showing only changes of commit ba783718d4 - Show all commits

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.
smillmorel 2026-09-16 12:03:22 -04:00

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -47,7 +47,7 @@ class AddNodalLoadsCommand(ProjectCommand):
self._forces = forces
self._pattern_id = pattern_id
self._new_pattern_name = new_pattern_name
self._new_ts_type = new_ts_type # "Linear" or "Constant"
self._new_ts_type = new_ts_type # "Linear" or "Constant"
self._created_ts: TimeSeries | None = None
self._created_pattern: PlainLoadPattern | None = None
self._added_loads: list[tuple[int, NodalLoad]] = [] # (pattern_id, load)
@ -101,7 +101,10 @@ class AddNodalLoadsCommand(ProjectCommand):
break
self._added_loads.clear()
# Roll back any infrastructure we created.
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
if (
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:
@ -157,7 +160,10 @@ class AddElementLoadsCommand(ProjectCommand):
pattern = self._resolve_pattern()
for eid in self._element_ids:
load = UniformElementLoad(
element_id=eid, wy=self._wy, wz=self._wz, wx=self._wx,
element_id=eid,
wy=self._wy,
wz=self._wz,
wx=self._wx,
)
pattern.element_loads.append(load)
self._added_loads.append((pattern.id, load))
@ -171,7 +177,10 @@ class AddElementLoadsCommand(ProjectCommand):
pat.element_loads.remove(load)
break
self._added_loads.clear()
if self._created_pattern is not None and self._created_pattern in self.project.load_patterns:
if (
self._created_pattern is not None
and self._created_pattern in self.project.load_patterns
):
self.project.load_patterns.remove(self._created_pattern)
self._created_pattern = None
if self._created_ts is not None and self._created_ts in self.project.time_series:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,11 +16,13 @@ class Entity(BaseModel):
"""Base class for every persisted domain object."""
model_config = ConfigDict(
frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore
frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore
validate_assignment=True,
populate_by_name=True,
)
id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.")
id: PositiveInt = Field(
..., description="Unique tag within its kind. Used as the OpenSees tag."
)
name: str = Field(default="", description="Optional human-readable label.")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,8 +15,9 @@ from __future__ import annotations
from pathlib import Path
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMainWindow
from PySide6.QtCore import QSettings
from PySide6.QtGui import QCloseEvent, QIcon
from PySide6.QtWidgets import QMainWindow, QMessageBox
from otko.viewmodels import AnalysisRunner, ProjectViewModel
from otko.views.action_handlers import ActionHandlers
@ -74,6 +75,7 @@ class MainWindow(
self._build_status_bar()
self._wire()
self._refresh_action_enablement()
self.restore_layout()
# ── construction ─────────────────────────────────────────────────
def _build_central_canvas(self) -> None:
@ -82,6 +84,54 @@ class MainWindow(
# Diagram overlay paints onto the same plotter as the model.
self._diagram_renderer = DiagramRenderer(self._canvas)
# ── layout persistence ───────────────────────────────────────────
def save_layout(self) -> None:
"""Persist window geometry and dock/toolbar state for next launch."""
settings = QSettings("OTKO", "OTKO")
settings.setValue("geometry", self.saveGeometry())
settings.setValue("windowState", self.saveState())
def restore_layout(self) -> None:
"""Restore a previously saved geometry/dock layout, if any.
On first run no values are stored, so the defaults from ``__init__``
(``resize`` + dock construction) stay in effect.
"""
settings = QSettings("OTKO", "OTKO")
geometry = settings.value("geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = settings.value("windowState")
if state is not None:
self.restoreState(state)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt override)
"""Prompt to save unsaved work, then persist the window layout.
The prompt is shown only for a visible window: Qt also closes hidden
widgets during teardown (for example under ``pytest-qt``), where a
modal dialog would block with no user to answer it.
"""
if self._vm.is_dirty and self.isVisible():
buttons = QMessageBox.StandardButton
choice = QMessageBox.warning(
self,
"Unsaved changes",
"Save changes to the current project before closing?",
buttons.Save | buttons.Discard | buttons.Cancel,
buttons.Save,
)
if choice == buttons.Cancel:
event.ignore()
return
if choice == buttons.Save:
self._on_save()
if self._vm.is_dirty:
event.ignore()
return
self.save_layout()
event.accept()
# ── wiring ────────────────────────────────────────────────────────
def _wire(self) -> None:
# File
@ -141,6 +191,7 @@ class MainWindow(
# Analyze
self._act_case_manager.triggered.connect(self._on_case_manager)
self._act_combo_manager.triggered.connect(self._on_combo_manager)
self._act_run.triggered.connect(self._on_run_analysis)
# Display
@ -156,7 +207,7 @@ class MainWindow(
self._act_back_to_model.triggered.connect(self._on_back_to_model)
# AnalysisRunner: stream log to console + show results in panel
self._runner.log.connect(self._console.appendPlainText)
self._runner.log.connect(self._console_dock.log)
self._runner.finished.connect(self._on_analysis_finished)
self._runner.failed.connect(self._on_analysis_failed)

View file

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

View file

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

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_truss import DrawTrussTool
__all__ = ["CanvasTool", "SelectTool", "ToolController",
"DrawFrameTool", "DrawNodeTool", "DrawTrussTool"]
__all__ = [
"CanvasTool",
"SelectTool",
"ToolController",
"DrawFrameTool",
"DrawNodeTool",
"DrawTrussTool",
]

View file

@ -84,10 +84,11 @@ class SelectTool(CanvasTool):
class ToolController(QObject):
"""Owns the active tool and routes canvas pick signals to it."""
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
toolChanged = Signal(object) # emits the new CanvasTool (or None for default)
def __init__(self, canvas: "ModelCanvas", vm: "ProjectViewModel",
parent: QObject | None = None) -> None:
def __init__(
self, canvas: "ModelCanvas", vm: "ProjectViewModel", parent: QObject | None = None
) -> None:
super().__init__(parent)
self._canvas = canvas
self._vm = vm

View file

@ -67,7 +67,9 @@ class DrawFrameTool(LinearElementDrawTool):
section_id = self._ensure_default_section()
element_id = project.next_element_id()
elem = ElasticBeamColumn(
id=element_id, nodes=(n1, n2), section_id=section_id,
id=element_id,
nodes=(n1, n2),
section_id=section_id,
)
self._vm.apply_command(AddElementsCommand(self._vm, [elem]))
finally:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,7 +23,9 @@ def test_mass_vector_reads_spinboxes(qtbot) -> None: # type: ignore[no-untyped-
dlg._mx.setValue(5000.0)
dlg._my.setValue(5000.0)
dlg._mz.setValue(100.0)
dlg._mxx.setValue(0.0); dlg._myy.setValue(0.0); dlg._mzz.setValue(0.0)
dlg._mxx.setValue(0.0)
dlg._myy.setValue(0.0)
dlg._mzz.setValue(0.0)
assert dlg.mass_vector() == (5000.0, 5000.0, 100.0, 0.0, 0.0, 0.0)

View file

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

View file

@ -23,7 +23,8 @@ from otko.core import ( # noqa: E402
# ────────────────────── logic helpers (no VTK) ──────────────────────
def _nearest_snap(
cx: float, cy: float,
cx: float,
cy: float,
world_pts: np.ndarray,
screen_pts: np.ndarray,
tol_px: float,
@ -33,7 +34,7 @@ def _nearest_snap(
return None
d2 = (screen_pts[:, 0] - cx) ** 2 + (screen_pts[:, 1] - cy) ** 2
idx = int(np.argmin(d2))
if d2[idx] <= tol_px ** 2:
if d2[idx] <= tol_px**2:
return tuple(float(v) for v in world_pts[idx]) # type: ignore[return-value]
return None
@ -65,6 +66,7 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
"""ModelCanvas._grid_intersections_world combines every visible system's
intersections (transformed by that system's origin/rotation)."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@ -101,15 +103,17 @@ def test_grid_intersections_world_includes_all_visible_systems(qtbot) -> None:
def test_grid_intersections_world_returns_none_without_grid(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project()) # default: Global system with no grid lines
canvas.show_project(Project()) # default: Global system with no grid lines
assert canvas._grid_intersections_world() is None
def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-untyped-def]
"""A system with ``hide_all=True`` must not contribute snap targets."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
@ -134,20 +138,23 @@ def test_hide_all_suppresses_intersections(qtbot) -> None: # type: ignore[no-un
def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untyped-def]
"""set_hover_snap(pt) creates an actor; passing None removes it."""
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
canvas.show_project(
Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0, 3.0]),
y_grid_lines=make_grid_lines("Y", [0.0, 4.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
),
),
),
],
))
],
)
)
r = canvas._renderer
assert r._hover_actor is None
r.set_hover_snap((3.0, 0.0, 0.0))
@ -158,20 +165,23 @@ def test_hover_snap_marker_round_trips(qtbot) -> None: # type: ignore[no-untype
def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
canvas.show_project(Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0]),
y_grid_lines=make_grid_lines("Y", [0.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
canvas.show_project(
Project(
coord_systems=[
CoordinateGridSystem(
name="Global",
grid=GridSystem(
x_grid_lines=make_grid_lines("X", [0.0]),
y_grid_lines=make_grid_lines("Y", [0.0]),
z_grid_lines=make_grid_lines("Z", [0.0]),
),
),
),
],
))
],
)
)
canvas.set_snap_preview_enabled(True)
canvas._renderer.set_hover_snap((0.0, 0.0, 0.0))
assert canvas._renderer._hover_actor is not None
@ -183,7 +193,7 @@ def test_snap_preview_flag_clears_marker(qtbot) -> None: # type: ignore[no-unty
def _pt_to_segment_d2(p: np.ndarray, a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Vectorised point-to-segment squared distance, used to verify logic."""
ab = b - a
ab_sq = (ab ** 2).sum(axis=1)
ab_sq = (ab**2).sum(axis=1)
ab_sq = np.where(ab_sq == 0, 1.0, ab_sq)
pa = p - a
t = (pa * ab).sum(axis=1) / ab_sq
@ -204,21 +214,21 @@ def test_point_to_segment_midpoint_hit() -> None:
def test_point_to_segment_endpoint_hit() -> None:
"""A click near an endpoint must also hit — the bug fix this test guards."""
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
p = np.array([1.0, 51.0]) # 1 px off node-a on a horizontal frame
a = np.array([[0.0, 50.0]])
b = np.array([[100.0, 50.0]])
d2 = _pt_to_segment_d2(p, a, b)
assert d2[0] <= 2.0 ** 2 # well inside a 5-pixel test tolerance
assert d2[0] <= 2.0**2 # well inside a 5-pixel test tolerance
def test_point_to_segment_orthogonal_miss() -> None:
"""Clicking far from a short frame must produce a large distance."""
p = np.array([50.0, 200.0])
a = np.array([[0.0, 50.0]])
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
b = np.array([[10.0, 50.0]]) # short frame at (0..10, 50)
d2 = _pt_to_segment_d2(p, a, b)
# Closest point is (10, 50), distance ≈ sqrt(40² + 150²) ≈ 155
assert d2[0] > 150.0 ** 2
assert d2[0] > 150.0**2
def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-untyped-def]
@ -226,6 +236,7 @@ def test_single_node_radius_uses_grid_extent(qtbot) -> None: # type: ignore[no-
grid bounds so the node remains visible (regression guard)."""
from otko.core import Node
from otko.views.canvas3d.model_canvas import ModelCanvas
canvas = ModelCanvas()
qtbot.addWidget(canvas)
p = Project(

View file

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

View file

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

View file

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

View file

@ -35,10 +35,15 @@ class _CanvasStub:
def _vm_with_two_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.apply_command(AddNodesCommand(vm, [
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]))
vm.apply_command(
AddNodesCommand(
vm,
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
],
)
)
return vm
@ -86,7 +91,7 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(1) # same node — should NOT create an element
tool.on_node_picked(1) # same node — should NOT create an element
assert vm.project.elements == []
# First-node state preserved so user can finish the gesture.
assert tool._first_node_id == 1
@ -150,6 +155,7 @@ def test_reset_clears_first_pick(qtbot) -> None: # type: ignore[no-untyped-def]
def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-untyped-def]
"""Two clicks on empty grid intersections → 2 nodes + 1 frame."""
from otko.core import GridSystem
vm = ProjectViewModel()
vm.new_project()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
@ -165,9 +171,9 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
tool.on_empty_clicked(0.0, 0.0, 0.0)
tool.on_empty_clicked(3.0, 4.0, 0.0)
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
elem = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
elem = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(elem, ElasticBeamColumn)
n1 = next(n for n in vm.project.nodes if n.id == elem.nodes[0]) # type: ignore[union-attr]
n2 = next(n for n in vm.project.nodes if n.id == elem.nodes[1]) # type: ignore[union-attr]
@ -179,18 +185,21 @@ def test_empty_clicks_snap_and_create_frame(qtbot) -> None: # type: ignore[no-u
def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-untyped-def]
"""An empty click at an existing node's location must not duplicate it."""
from otko.core import GridSystem
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
vm = _vm_with_two_nodes() # nodes 1, 2 at (0,0,0) and (3,0,0)
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
x_lines=[0.0, 3.0], y_lines=[0.0], z_lines=[0.0],
x_lines=[0.0, 3.0],
y_lines=[0.0],
z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
tool.on_empty_clicked(0.0, 0.0, 0.0) # existing node 1 at (0,0,0)
tool.on_empty_clicked(3.0, 0.0, 0.0) # existing node 2 at (3,0,0)
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
elem = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr] (no new nodes)
elem = vm.project.elements[0] # type: ignore[union-attr]
assert set(elem.nodes) == {1, 2}
@ -198,19 +207,22 @@ def test_empty_click_reuses_coincident_node(qtbot) -> None: # type: ignore[no-u
def test_mixed_node_pick_then_empty_click(qtbot) -> None: # type: ignore[no-untyped-def]
"""First click picks existing node; second click creates new node + frame."""
from otko.core import GridSystem
vm = _vm_with_two_nodes()
vm.project.grid_system = GridSystem( # type: ignore[union-attr]
x_lines=[0.0, 3.0, 6.0], y_lines=[0.0], z_lines=[0.0],
x_lines=[0.0, 3.0, 6.0],
y_lines=[0.0],
z_lines=[0.0],
)
tool = DrawFrameTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_node_picked(1) # start at node 1 = (0,0,0)
tool.on_node_picked(1) # start at node 1 = (0,0,0)
tool.on_empty_clicked(6.0, 0.0, 0.0) # canvas emits exact snap
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
assert len(vm.project.nodes) == 3 # type: ignore[union-attr] (new node added)
assert len(vm.project.elements) == 1
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
new_node = vm.project.nodes[-1] # type: ignore[union-attr]
assert new_node.coords == (6.0, 0.0, 0.0)
elem = vm.project.elements[0]
assert set(elem.nodes) == {1, new_node.id}

View file

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

View file

@ -34,10 +34,12 @@ class _CanvasStub:
def _vm_with_grid_and_nodes() -> ProjectViewModel:
vm = ProjectViewModel()
vm.new_project()
vm.project.nodes.extend([
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
])
vm.project.nodes.extend(
[
Node(id=1, coords=(0, 0, 0)),
Node(id=2, coords=(3, 0, 0)),
]
)
vm.project.coord_systems = [
CoordinateGridSystem(
name="Global",
@ -60,13 +62,13 @@ def test_two_clicks_create_truss(qtbot) -> None: # type: ignore[no-untyped-def]
tool.on_node_picked(1)
tool.on_node_picked(2)
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert isinstance(el, TrussElement)
assert el.nodes == (1, 2)
assert el.area > 0
# A default ElasticUniaxial must have been created for the material.
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
assert len(vm.project.materials) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.materials[0], ElasticUniaxial)
@ -78,9 +80,9 @@ def test_draw_truss_reuses_existing_material(qtbot) -> None: # type: ignore[no-
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(2)
el = vm.project.elements[0] # type: ignore[union-attr]
el = vm.project.elements[0] # type: ignore[union-attr]
assert el.material_id == 1
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
assert len(vm.project.materials) == 1 # type: ignore[union-attr] (no new material)
@pytest.mark.gui
@ -104,8 +106,8 @@ def test_empty_click_creates_node_then_truss(qtbot) -> None: # type: ignore[no-
tool.on_empty_clicked(3.0, 0.0, 0.0)
# Two nodes created + one truss element.
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
assert len(vm.project.nodes) == 2 # type: ignore[union-attr]
assert len(vm.project.elements) == 1 # type: ignore[union-attr]
assert isinstance(vm.project.elements[0], TrussElement)
@ -115,5 +117,5 @@ def test_self_pick_is_ignored(qtbot) -> None: # type: ignore[no-untyped-def]
tool = DrawTrussTool(_CanvasStub(), vm) # type: ignore[arg-type]
tool.activate()
tool.on_node_picked(1)
tool.on_node_picked(1) # same node — must not create a zero-length truss
assert vm.project.elements == [] # type: ignore[union-attr]
tool.on_node_picked(1) # same node — must not create a zero-length truss
assert vm.project.elements == [] # type: ignore[union-attr]

View file

@ -19,11 +19,18 @@ from otko.core import ( # noqa: E402
@pytest.mark.gui
def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
dlg._x_n.setValue(4); dlg._x_s.setValue(3.0); dlg._x_f.setValue(0.0)
dlg._y_n.setValue(2); dlg._y_s.setValue(4.0); dlg._y_f.setValue(0.0)
dlg._z_n.setValue(3); dlg._z_s.setValue(3.0); dlg._z_f.setValue(-3.0)
dlg._x_n.setValue(4)
dlg._x_s.setValue(3.0)
dlg._x_f.setValue(0.0)
dlg._y_n.setValue(2)
dlg._y_s.setValue(4.0)
dlg._y_f.setValue(0.0)
dlg._z_n.setValue(3)
dlg._z_s.setValue(3.0)
dlg._z_f.setValue(-3.0)
xs, ys, zs = dlg.ordinates()
assert xs == [0.0, 3.0, 6.0, 9.0]
assert ys == [0.0, 4.0]
@ -33,6 +40,7 @@ def test_quick_grid_lines_produces_ordinates(qtbot) -> None: # type: ignore[no-
@pytest.mark.gui
def test_quick_grid_zero_lines_produces_empty(qtbot) -> None: # type: ignore[no-untyped-def]
from otko.views.dialogs.quick_grid_lines import QuickGridLinesDialog
dlg = QuickGridLinesDialog()
qtbot.addWidget(dlg)
dlg._x_n.setValue(0)
@ -48,6 +56,7 @@ def test_locate_origin_round_trip(qtbot) -> None: # type: ignore[no-untyped-def
from otko.views.dialogs.locate_origin import (
CoordSystemLocationOrientationDialog,
)
dlg = CoordSystemLocationOrientationDialog(
origin=(1.5, -2.0, 3.0),
rotation_deg=(0.0, 0.0, 45.0),
@ -65,6 +74,7 @@ def test_define_grid_data_round_trip(qtbot) -> None: # type: ignore[no-untyped-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
cs = CoordinateGridSystem(
name="Floor2",
coord=CoordinateSystem(origin=(0, 0, 3.5)),
@ -96,6 +106,7 @@ def test_define_grid_data_spacing_mode_conversion(qtbot) -> None: # type: ignor
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
cs = CoordinateGridSystem(
name="Test",
grid=GridSystem(x_grid_lines=make_grid_lines("X", [0.0, 3.0, 6.0, 10.0])),
@ -117,6 +128,7 @@ def test_define_grid_data_global_name_locked(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog(is_global=True)
qtbot.addWidget(dlg)
assert dlg._name_edit.text() == "Global"
@ -130,6 +142,7 @@ def test_define_grid_data_add_and_delete_row(qtbot) -> None: # type: ignore[no-
from otko.views.dialogs.define_grid_data import (
DefineGridSystemDataDialog,
)
dlg = DefineGridSystemDataDialog()
qtbot.addWidget(dlg)
# Start with empty X table.

View file

@ -20,3 +20,22 @@ def test_main_window_opens(qtbot) -> None: # type: ignore[no-untyped-def]
window.show()
assert window.isVisible()
assert window.windowTitle() == "OTKO"
@pytest.mark.gui
def test_close_dirty_hidden_window_does_not_block(qtbot) -> None: # type: ignore[no-untyped-def]
"""Closing a dirty but hidden window must not raise a modal prompt.
Qt closes hidden widgets during teardown (as ``pytest-qt`` does); a
modal "unsaved changes" dialog there would block forever with no user
to answer it. The prompt is therefore gated on visibility.
"""
from otko.views.main_window import MainWindow
window = MainWindow()
qtbot.addWidget(window)
window._vm.new_project()
window._vm.mark_dirty()
assert not window.isVisible()
assert window.close() is True
assert window._vm.is_dirty # unchanged: the prompt was skipped

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