Add UI-agnostic model layer: typed grouped properties (string/int/float/ bool/enum/color/catalogue item(s)/value list), catalogue registry, port relations with color schemes and compatibility rules, ports, node/edge models, and DiagramDocument with change signals and JSON round-trip. Include a starter node library (source/tank/pump/valve/junction/consumer/ transformer) with matching SVG symbols, plus model+document tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
99 lines
2.9 KiB
Python
99 lines
2.9 KiB
Python
"""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())
|