diff --git a/src/otko/core/__init__.py b/src/otko/core/__init__.py index 9443696..081a941 100644 --- a/src/otko/core/__init__.py +++ b/src/otko/core/__init__.py @@ -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", diff --git a/src/otko/core/_base.py b/src/otko/core/_base.py index 72e9d2f..41b88c0 100644 --- a/src/otko/core/_base.py +++ b/src/otko/core/_base.py @@ -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`` for nodes, ``E`` 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 diff --git a/src/otko/core/geometry/__init__.py b/src/otko/core/geometry/__init__.py index 705c35d..7e1dbff 100644 --- a/src/otko/core/geometry/__init__.py +++ b/src/otko/core/geometry/__init__.py @@ -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", diff --git a/src/otko/core/geometry/elements.py b/src/otko/core/geometry/elements.py index f78588f..733f2bc 100644 --- a/src/otko/core/geometry/elements.py +++ b/src/otko/core/geometry/elements.py @@ -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`` 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``).""" + 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( diff --git a/src/otko/core/geometry/node.py b/src/otko/core/geometry/node.py index d37e3bf..e882b26 100644 --- a/src/otko/core/geometry/node.py +++ b/src/otko/core/geometry/node.py @@ -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``).""" + return f"N{self.id}" + @property def is_restrained(self) -> bool: """True if any DOF is fixed.""" diff --git a/src/otko/views/canvas3d/model_renderer.py b/src/otko/views/canvas3d/model_renderer.py index 568c079..2be41c4 100644 --- a/src/otko/views/canvas3d/model_renderer.py +++ b/src/otko/views/canvas3d/model_renderer.py @@ -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( diff --git a/src/otko/views/docks/table_dock.py b/src/otko/views/docks/table_dock.py index 23a2592..2f28fa6 100644 --- a/src/otko/views/docks/table_dock.py +++ b/src/otko/views/docks/table_dock.py @@ -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" (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" (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: diff --git a/tests/unit/test_display_name.py b/tests/unit/test_display_name.py new file mode 100644 index 0000000..bb10d09 --- /dev/null +++ b/tests/unit/test_display_name.py @@ -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()