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

111 lines
3.8 KiB
Python

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