feat: show default node/element names in table
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled

This commit is contained in:
smillmorel 2026-09-08 03:41:10 -04:00
commit 13bb5d1d91
8 changed files with 172 additions and 21 deletions

View file

@ -35,6 +35,7 @@ from otko.core.geometry import (
DispBeamColumn,
ElasticBeamColumn,
Element,
ElementBase,
ForceBeamColumn,
GridLine,
GridSystem,
@ -114,6 +115,7 @@ __all__ = [
"ensure_default_truss_material",
"ensure_default_timeseries_and_pattern",
"Element",
"ElementBase",
"TrussElement",
"CorotTrussElement",
"ElasticBeamColumn",

View file

@ -16,11 +16,31 @@ 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.")
@property
def default_name(self) -> str:
"""Fallback label used when ``name`` is blank.
Display-only — reading it never writes back into ``name`` and it
is never persisted. Subclasses override this with their prefix
(e.g. ``N<id>`` for nodes, ``E<id>`` for elements).
"""
return ""
@property
def display_name(self) -> str:
"""Explicit ``name`` stripped, or ``default_name`` when blank.
Display-only fallback — reading this never mutates ``name``.
"""
return self.name.strip() or self.default_name

View file

@ -6,6 +6,7 @@ from otko.core.geometry.elements import (
DispBeamColumn,
ElasticBeamColumn,
Element,
ElementBase,
ForceBeamColumn,
QuadElement,
TrussElement,
@ -25,6 +26,7 @@ from otko.core.geometry.node import Node
__all__ = [
"Node",
"Element",
"ElementBase",
"TrussElement",
"CorotTrussElement",
"ElasticBeamColumn",

View file

@ -17,7 +17,21 @@ from pydantic import Field, PositiveFloat, PositiveInt
from otko.core._base import Entity
class TrussElement(Entity):
class ElementBase(Entity):
"""Shared base for all structural elements.
Carries the display-only ``E<id>`` fallback label so every element
type (including Quad) shows the same default name on the canvas
and in the table without persisting it into ``name``.
"""
@property
def default_name(self) -> str:
"""Fallback label for unnamed elements (``E<id>``)."""
return f"E{self.id}"
class TrussElement(ElementBase):
"""Two-node truss with axial stiffness only — ``element truss``."""
type: Literal["Truss"] = "Truss"
@ -27,7 +41,7 @@ class TrussElement(Entity):
rho: float = Field(default=0.0, ge=0.0, description="Mass per unit length.")
class CorotTrussElement(Entity):
class CorotTrussElement(ElementBase):
"""Co-rotational truss for large displacements — ``element corotTruss``."""
type: Literal["CorotTruss"] = "CorotTruss"
@ -37,7 +51,7 @@ class CorotTrussElement(Entity):
rho: float = Field(default=0.0, ge=0.0)
class ElasticBeamColumn(Entity):
class ElasticBeamColumn(ElementBase):
"""Linear-elastic frame element — ``element elasticBeamColumn``.
Pure-section style: provide section_id; OpenSees pulls EA, EI from
@ -60,7 +74,7 @@ class ElasticBeamColumn(Entity):
)
class ForceBeamColumn(Entity):
class ForceBeamColumn(ElementBase):
"""Force-based fibre frame element — ``element forceBeamColumn``."""
type: Literal["ForceBeamColumn"] = "ForceBeamColumn"
@ -72,7 +86,7 @@ class ForceBeamColumn(Entity):
tolerance: float = Field(default=1e-12, gt=0.0)
class DispBeamColumn(Entity):
class DispBeamColumn(ElementBase):
"""Displacement-based fibre frame element — ``element dispBeamColumn``."""
type: Literal["DispBeamColumn"] = "DispBeamColumn"
@ -82,7 +96,7 @@ class DispBeamColumn(Entity):
geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear"
class ZeroLengthElement(Entity):
class ZeroLengthElement(ElementBase):
"""Two coincident nodes connected by uniaxial materials per DOF.
Foundation building block for plastic hinges and isolators.
@ -91,7 +105,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=(
@ -104,7 +120,7 @@ class ZeroLengthElement(Entity):
)
class ZeroLengthSectionElement(Entity):
class ZeroLengthSectionElement(ElementBase):
"""Two coincident nodes connected by a full :class:`Section` —
OpenSees ``element zeroLengthSection``.
@ -121,10 +137,12 @@ 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):
class BeamWithHingesElement(ElementBase):
"""Force-based beam with lumped plasticity at both ends —
``element beamWithHinges``.
@ -156,7 +174,7 @@ class BeamWithHingesElement(Entity):
geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear"
class QuadElement(Entity):
class QuadElement(ElementBase):
"""Four-node 2D quadrilateral (plane-stress / plane-strain) —
``element quad`` / ``bbarQuad`` / ``enhancedQuad``.
@ -179,7 +197,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

@ -33,6 +33,11 @@ class Node(Entity):
mass: Mass6 = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
restraint: Restraint6 = (False, False, False, False, False, False)
@property
def default_name(self) -> str:
"""Fallback label for unnamed nodes (``N<id>``)."""
return f"N{self.id}"
@property
def is_restrained(self) -> bool:
"""True if any DOF is fixed."""

View file

@ -1140,9 +1140,7 @@ class ModelRenderer:
if self._node_pd is None or len(project.nodes) == 0:
return None
pts = np.asarray(self._node_pd.points)
labels = [
(node.name.strip() if node.name.strip() else f"N{node.id}") for node in project.nodes
]
labels = [node.display_name for node in project.nodes]
return self._plotter.add_point_labels(
pts,
labels,
@ -1170,7 +1168,7 @@ class ModelRenderer:
if i is None or j is None:
continue
centers.append((pts[i] + pts[j]) / 2.0)
labels.append(el.name.strip() if el.name.strip() else f"E{el.id}")
labels.append(el.display_name)
if not centers:
return None
return self._plotter.add_point_labels(

View file

@ -492,7 +492,7 @@ class TableDock(QWidget):
table.setRowCount(len(nodes))
for r, n in enumerate(nodes):
self._put(table, r, 0, str(n.id), editable=False, data=n.id)
self._put(table, r, 1, n.name, data=n.id)
self._put(table, r, 1, n.display_name, data=n.id)
for c, v in enumerate(n.coords, start=2):
self._put(table, r, c, f"{v:g}", data=n.id)
for c, v in enumerate(n.mass, start=5):
@ -506,6 +506,12 @@ class TableDock(QWidget):
nid = self._row_key(table, row)
node = project.node(nid)
if col == 1:
if not node.name.strip() and item.text().strip() == node.display_name:
# Confirmed the displayed fallback unchanged — not a real
# rename, so leave the stored name blank instead of
# persisting "N<id>" (and keep it off the undo stack).
self.refresh()
return
updated = self._dry_run(node, {"name": item.text()})
self._dispatch(UpdateNodeFieldsCommand(vm, nid, {"name": updated.name}))
elif 2 <= col <= 4:
@ -537,7 +543,7 @@ class TableDock(QWidget):
table.setRowCount(len(elements))
for r, el in enumerate(elements):
self._put(table, r, 0, str(el.id), editable=False, data=el.id)
self._put(table, r, 1, el.name, data=el.id)
self._put(table, r, 1, el.display_name, data=el.id)
self._put(table, r, 2, el.type, editable=False, data=el.id)
self._put(table, r, 3, ", ".join(str(n) for n in el.nodes), data=el.id)
self._put(
@ -583,6 +589,12 @@ class TableDock(QWidget):
eid = self._row_key(table, row)
el = project.element(eid)
if col == 1:
if not el.name.strip() and item.text().strip() == el.display_name:
# Confirmed the displayed fallback unchanged — not a real
# rename, so leave the stored name blank instead of
# persisting "E<id>" (and keep it off the undo stack).
self.refresh()
return
updated = self._dry_run(el, {"name": item.text()})
self._dispatch(UpdateElementFieldsCommand(vm, eid, {"name": updated.name}))
elif col == 3:

View file

@ -0,0 +1,93 @@
"""Unit tests for the display-only ``display_name`` fallback on entities."""
from __future__ import annotations
from otko.core import (
BeamWithHingesElement,
CorotTrussElement,
DispBeamColumn,
ElasticBeamColumn,
ElementBase,
ForceBeamColumn,
Node,
QuadElement,
TrussElement,
ZeroLengthElement,
ZeroLengthSectionElement,
)
from otko.core._base import Entity
_ALL_ELEMENT_TYPES = (
TrussElement,
CorotTrussElement,
ElasticBeamColumn,
ForceBeamColumn,
DispBeamColumn,
ZeroLengthElement,
ZeroLengthSectionElement,
BeamWithHingesElement,
QuadElement,
)
class TestEntityBase:
def test_base_fallback_is_empty(self) -> None:
e = Entity(id=1)
assert e.default_name == ""
assert e.display_name == ""
def test_display_name_is_not_persisted(self) -> None:
assert "display_name" not in Entity(id=1).model_dump()
assert "default_name" not in Entity(id=1).model_dump()
assert "display_name" not in Node(id=1, coords=(0, 0, 0)).model_dump()
class TestNodeDisplayName:
def test_blank_name_falls_back(self) -> None:
n = Node(id=1, coords=(0, 0, 0))
assert n.display_name == "N1"
def test_explicit_name_is_returned(self) -> None:
n = Node(id=1, name="Support A", coords=(0, 0, 0))
assert n.display_name == "Support A"
def test_whitespace_only_name_falls_back(self) -> None:
n = Node(id=2, name=" ", coords=(0, 0, 0))
assert n.display_name == "N2"
def test_explicit_name_is_stripped(self) -> None:
n = Node(id=1, name=" A1 ", coords=(0, 0, 0))
assert n.display_name == "A1"
def test_display_only_never_writes_back(self) -> None:
n = Node(id=1, coords=(0, 0, 0))
assert n.display_name == "N1"
assert n.name == ""
class TestElementDisplayName:
def test_all_element_types_share_element_base(self) -> None:
for cls in _ALL_ELEMENT_TYPES:
assert issubclass(cls, ElementBase)
def test_blank_truss_name_falls_back(self) -> None:
t = TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)
assert t.display_name == "E1"
def test_explicit_truss_name_is_returned(self) -> None:
t = TrussElement(id=1, name="Chord", nodes=(1, 2), area=0.01, material_id=1)
assert t.display_name == "Chord"
def test_whitespace_only_truss_name_falls_back(self) -> None:
t = TrussElement(id=3, name=" ", nodes=(1, 2), area=0.01, material_id=1)
assert t.display_name == "E3"
def test_blank_quad_name_falls_back(self) -> None:
q = QuadElement(id=1, nodes=(1, 2, 3, 4), thickness=0.1, material_id=1)
assert q.display_name == "E1"
def test_display_only_never_writes_back(self) -> None:
t = TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)
assert t.display_name == "E1"
assert t.name == ""
assert "display_name" not in t.model_dump()