"""Ports: typed connection anchors positioned on a node's boundary.""" from __future__ import annotations from enum import Enum from typing import Optional class PortSide(str, Enum): LEFT = "left" RIGHT = "right" TOP = "top" BOTTOM = "bottom" class PortDirection(str, Enum): """Whether a port emits, receives, or does both.""" INPUT = "input" OUTPUT = "output" BIDIRECTIONAL = "bidirectional" class Port: """A connection anchor on a node. Position is stored as a normalized offset (0..1) along the given side, so it scales with the node's bounding box. Attributes: port_id: unique within the owning node. name: display name. relation: relation key (see :mod:`relations`). side: which edge of the node it sits on. offset: 0..1 position along that side. direction: input / output / bidirectional. """ __slots__ = ("port_id", "name", "relation", "side", "offset", "direction") def __init__( self, port_id: str, relation: str, side: PortSide, offset: float = 0.5, *, name: str = "", direction: PortDirection = PortDirection.BIDIRECTIONAL, ) -> None: self.port_id = port_id self.name = name or port_id self.relation = relation self.side = PortSide(side) self.offset = min(1.0, max(0.0, float(offset))) self.direction = PortDirection(direction) def local_point(self, width: float, height: float) -> tuple[float, float]: """Return the port position in node-local coordinates (origin at 0,0).""" if self.side is PortSide.LEFT: return 0.0, height * self.offset if self.side is PortSide.RIGHT: return width, height * self.offset if self.side is PortSide.TOP: return width * self.offset, 0.0 return width * self.offset, height # BOTTOM def normal(self) -> tuple[float, float]: """Outward unit normal for the port's side (used to stub-out edges).""" return { PortSide.LEFT: (-1.0, 0.0), PortSide.RIGHT: (1.0, 0.0), PortSide.TOP: (0.0, -1.0), PortSide.BOTTOM: (0.0, 1.0), }[self.side] def to_dict(self) -> dict: return { "id": self.port_id, "name": self.name, "relation": self.relation, "side": self.side.value, "offset": self.offset, "direction": self.direction.value, } @classmethod def from_dict(cls, data: dict) -> "Port": return cls( data["id"], data["relation"], PortSide(data["side"]), data.get("offset", 0.5), name=data.get("name", ""), direction=PortDirection(data.get("direction", "bidirectional")), ) def clone(self) -> "Port": return Port.from_dict(self.to_dict())