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>
111 lines
3.8 KiB
Python
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)
|