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, DispBeamColumn,
ElasticBeamColumn, ElasticBeamColumn,
Element, Element,
ElementBase,
ForceBeamColumn, ForceBeamColumn,
GridLine, GridLine,
GridSystem, GridSystem,
@ -114,6 +115,7 @@ __all__ = [
"ensure_default_truss_material", "ensure_default_truss_material",
"ensure_default_timeseries_and_pattern", "ensure_default_timeseries_and_pattern",
"Element", "Element",
"ElementBase",
"TrussElement", "TrussElement",
"CorotTrussElement", "CorotTrussElement",
"ElasticBeamColumn", "ElasticBeamColumn",

View file

@ -16,11 +16,31 @@ class Entity(BaseModel):
"""Base class for every persisted domain object.""" """Base class for every persisted domain object."""
model_config = ConfigDict( model_config = ConfigDict(
frozen=False, # individual setters allowed; we lock at the Project boundary frozen=False, # individual setters allowed; we lock at the Project boundary
extra="forbid", # unknown JSON keys are an error, not a silent ignore extra="forbid", # unknown JSON keys are an error, not a silent ignore
validate_assignment=True, validate_assignment=True,
populate_by_name=True, populate_by_name=True,
) )
id: PositiveInt = Field(..., description="Unique tag within its kind. Used as the OpenSees tag.") id: PositiveInt = Field(
..., description="Unique tag within its kind. Used as the OpenSees tag."
)
name: str = Field(default="", description="Optional human-readable label.") name: str = Field(default="", description="Optional human-readable label.")
@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, DispBeamColumn,
ElasticBeamColumn, ElasticBeamColumn,
Element, Element,
ElementBase,
ForceBeamColumn, ForceBeamColumn,
QuadElement, QuadElement,
TrussElement, TrussElement,
@ -25,6 +26,7 @@ from otko.core.geometry.node import Node
__all__ = [ __all__ = [
"Node", "Node",
"Element", "Element",
"ElementBase",
"TrussElement", "TrussElement",
"CorotTrussElement", "CorotTrussElement",
"ElasticBeamColumn", "ElasticBeamColumn",

View file

@ -17,7 +17,21 @@ from pydantic import Field, PositiveFloat, PositiveInt
from otko.core._base import Entity 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``.""" """Two-node truss with axial stiffness only — ``element truss``."""
type: Literal["Truss"] = "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.") 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``.""" """Co-rotational truss for large displacements — ``element corotTruss``."""
type: Literal["CorotTruss"] = "CorotTruss" type: Literal["CorotTruss"] = "CorotTruss"
@ -37,7 +51,7 @@ class CorotTrussElement(Entity):
rho: float = Field(default=0.0, ge=0.0) rho: float = Field(default=0.0, ge=0.0)
class ElasticBeamColumn(Entity): class ElasticBeamColumn(ElementBase):
"""Linear-elastic frame element — ``element elasticBeamColumn``. """Linear-elastic frame element — ``element elasticBeamColumn``.
Pure-section style: provide section_id; OpenSees pulls EA, EI from 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``.""" """Force-based fibre frame element — ``element forceBeamColumn``."""
type: Literal["ForceBeamColumn"] = "ForceBeamColumn" type: Literal["ForceBeamColumn"] = "ForceBeamColumn"
@ -72,7 +86,7 @@ class ForceBeamColumn(Entity):
tolerance: float = Field(default=1e-12, gt=0.0) tolerance: float = Field(default=1e-12, gt=0.0)
class DispBeamColumn(Entity): class DispBeamColumn(ElementBase):
"""Displacement-based fibre frame element — ``element dispBeamColumn``.""" """Displacement-based fibre frame element — ``element dispBeamColumn``."""
type: Literal["DispBeamColumn"] = "DispBeamColumn" type: Literal["DispBeamColumn"] = "DispBeamColumn"
@ -82,7 +96,7 @@ class DispBeamColumn(Entity):
geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear" geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear"
class ZeroLengthElement(Entity): class ZeroLengthElement(ElementBase):
"""Two coincident nodes connected by uniaxial materials per DOF. """Two coincident nodes connected by uniaxial materials per DOF.
Foundation building block for plastic hinges and isolators. Foundation building block for plastic hinges and isolators.
@ -91,7 +105,9 @@ class ZeroLengthElement(Entity):
type: Literal["ZeroLength"] = "ZeroLength" type: Literal["ZeroLength"] = "ZeroLength"
nodes: tuple[PositiveInt, PositiveInt] nodes: tuple[PositiveInt, PositiveInt]
material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1) material_ids: tuple[PositiveInt, ...] = Field(..., min_length=1)
dofs: tuple[int, ...] = Field(..., min_length=1, description="DOF directions, 1-indexed (1..6).") dofs: tuple[int, ...] = Field(
..., min_length=1, description="DOF directions, 1-indexed (1..6)."
)
do_rayleigh: bool = Field( do_rayleigh: bool = Field(
default=False, default=False,
description=( description=(
@ -104,7 +120,7 @@ class ZeroLengthElement(Entity):
) )
class ZeroLengthSectionElement(Entity): class ZeroLengthSectionElement(ElementBase):
"""Two coincident nodes connected by a full :class:`Section` — """Two coincident nodes connected by a full :class:`Section` —
OpenSees ``element zeroLengthSection``. OpenSees ``element zeroLengthSection``.
@ -121,10 +137,12 @@ class ZeroLengthSectionElement(Entity):
type: Literal["ZeroLengthSection"] = "ZeroLengthSection" type: Literal["ZeroLengthSection"] = "ZeroLengthSection"
nodes: tuple[PositiveInt, PositiveInt] nodes: tuple[PositiveInt, PositiveInt]
section_id: PositiveInt = Field(..., description="Section attached to the two coincident nodes.") section_id: PositiveInt = Field(
..., description="Section attached to the two coincident nodes."
)
class BeamWithHingesElement(Entity): class BeamWithHingesElement(ElementBase):
"""Force-based beam with lumped plasticity at both ends — """Force-based beam with lumped plasticity at both ends —
``element beamWithHinges``. ``element beamWithHinges``.
@ -156,7 +174,7 @@ class BeamWithHingesElement(Entity):
geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear" geom_transf: Literal["Linear", "PDelta", "Corotational"] = "Linear"
class QuadElement(Entity): class QuadElement(ElementBase):
"""Four-node 2D quadrilateral (plane-stress / plane-strain) — """Four-node 2D quadrilateral (plane-stress / plane-strain) —
``element quad`` / ``bbarQuad`` / ``enhancedQuad``. ``element quad`` / ``bbarQuad`` / ``enhancedQuad``.
@ -179,7 +197,8 @@ class QuadElement(Entity):
description="Surface pressure applied over the element (force / area).", description="Surface pressure applied over the element (force / area).",
) )
rho: float = Field( rho: float = Field(
default=0.0, ge=0.0, default=0.0,
ge=0.0,
description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.", description="Mass density override (kip·s²/in⁴). Leave 0 to use material rho.",
) )
b1: float = Field( b1: float = Field(

View file

@ -33,6 +33,11 @@ class Node(Entity):
mass: Mass6 = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0) mass: Mass6 = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
restraint: Restraint6 = (False, False, False, False, False, False) 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 @property
def is_restrained(self) -> bool: def is_restrained(self) -> bool:
"""True if any DOF is fixed.""" """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: if self._node_pd is None or len(project.nodes) == 0:
return None return None
pts = np.asarray(self._node_pd.points) pts = np.asarray(self._node_pd.points)
labels = [ labels = [node.display_name for node in project.nodes]
(node.name.strip() if node.name.strip() else f"N{node.id}") for node in project.nodes
]
return self._plotter.add_point_labels( return self._plotter.add_point_labels(
pts, pts,
labels, labels,
@ -1170,7 +1168,7 @@ class ModelRenderer:
if i is None or j is None: if i is None or j is None:
continue continue
centers.append((pts[i] + pts[j]) / 2.0) 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: if not centers:
return None return None
return self._plotter.add_point_labels( return self._plotter.add_point_labels(

View file

@ -492,7 +492,7 @@ class TableDock(QWidget):
table.setRowCount(len(nodes)) table.setRowCount(len(nodes))
for r, n in enumerate(nodes): for r, n in enumerate(nodes):
self._put(table, r, 0, str(n.id), editable=False, data=n.id) 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): for c, v in enumerate(n.coords, start=2):
self._put(table, r, c, f"{v:g}", data=n.id) self._put(table, r, c, f"{v:g}", data=n.id)
for c, v in enumerate(n.mass, start=5): for c, v in enumerate(n.mass, start=5):
@ -506,6 +506,12 @@ class TableDock(QWidget):
nid = self._row_key(table, row) nid = self._row_key(table, row)
node = project.node(nid) node = project.node(nid)
if col == 1: 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()}) updated = self._dry_run(node, {"name": item.text()})
self._dispatch(UpdateNodeFieldsCommand(vm, nid, {"name": updated.name})) self._dispatch(UpdateNodeFieldsCommand(vm, nid, {"name": updated.name}))
elif 2 <= col <= 4: elif 2 <= col <= 4:
@ -537,7 +543,7 @@ class TableDock(QWidget):
table.setRowCount(len(elements)) table.setRowCount(len(elements))
for r, el in enumerate(elements): for r, el in enumerate(elements):
self._put(table, r, 0, str(el.id), editable=False, data=el.id) 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, 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(table, r, 3, ", ".join(str(n) for n in el.nodes), data=el.id)
self._put( self._put(
@ -583,6 +589,12 @@ class TableDock(QWidget):
eid = self._row_key(table, row) eid = self._row_key(table, row)
el = project.element(eid) el = project.element(eid)
if col == 1: 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()}) updated = self._dry_run(el, {"name": item.text()})
self._dispatch(UpdateElementFieldsCommand(vm, eid, {"name": updated.name})) self._dispatch(UpdateElementFieldsCommand(vm, eid, {"name": updated.name}))
elif col == 3: 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()