"""NodeModel: a placed diagram node with geometry, ports and properties.""" from __future__ import annotations from typing import Iterable, Optional from .port import Port from .properties import PropertyBag class NodeModel: """A node instance placed on the canvas. Geometry (``x``, ``y``, ``width``, ``height``, ``rotation``) are first-class attributes used by the view. Presentation (title, color, ...) and physics properties live in :attr:`properties`. The display title is sourced from the ``title`` property so there is a single source of truth. """ def __init__( self, node_id: str, template_key: str, *, svg_name: str = "", x: float = 0.0, y: float = 0.0, width: float = 80.0, height: float = 80.0, rotation: float = 0.0, ports: Optional[Iterable[Port]] = None, properties: Optional[PropertyBag] = None, ) -> None: self.node_id = node_id self.template_key = template_key self.svg_name = svg_name self.x = float(x) self.y = float(y) self.width = float(width) self.height = float(height) self.rotation = float(rotation) self.ports: list[Port] = list(ports or []) self.properties: PropertyBag = properties if properties is not None else PropertyBag() # -- convenience ------------------------------------------------------ @property def title(self) -> str: return self.properties.value("title", "") or "" @title.setter def title(self, value: str) -> None: if not self.properties.set_value("title", value): # Ensure the property exists if it was missing. from .properties import Property, PropertyGroup, PropertyType grp = self.properties.group("Presentation") if grp is None: grp = self.properties.add_group(PropertyGroup("Presentation")) if grp.get("title") is None: grp.add(Property("title", "Title", PropertyType.STRING, value)) def port(self, port_id: str) -> Optional[Port]: for p in self.ports: if p.port_id == port_id: return p return None def port_scene_point(self, port_id: str) -> Optional[tuple[float, float]]: """Absolute scene coordinates of a port (ignores rotation for routing).""" p = self.port(port_id) if p is None: return None lx, ly = p.local_point(self.width, self.height) return self.x + lx, self.y + ly def center(self) -> tuple[float, float]: return self.x + self.width / 2.0, self.y + self.height / 2.0 # -- serialization ---------------------------------------------------- def to_dict(self) -> dict: return { "id": self.node_id, "template": self.template_key, "svg": self.svg_name, "x": self.x, "y": self.y, "width": self.width, "height": self.height, "rotation": self.rotation, "ports": [p.to_dict() for p in self.ports], "properties": self.properties.to_dict(), } @classmethod def from_dict(cls, data: dict) -> "NodeModel": return cls( data["id"], data["template"], svg_name=data.get("svg", ""), x=data.get("x", 0.0), y=data.get("y", 0.0), width=data.get("width", 80.0), height=data.get("height", 80.0), rotation=data.get("rotation", 0.0), ports=[Port.from_dict(p) for p in data.get("ports", [])], properties=PropertyBag.from_dict(data.get("properties", {})), ) def clone(self, new_id: str) -> "NodeModel": d = self.to_dict() d["id"] = new_id return NodeModel.from_dict(d)