Ilya 62780c66be feat(model): domain model, node library, and SVG symbols
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>
2026-07-02 23:13:18 +02:00

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())