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

205 lines
7.6 KiB
Python

"""Built-in node templates: SVG symbol + port configuration + default props.
A :class:`NodeTemplate` is a reusable definition dragged from the node panel to
create :class:`NodeModel` instances on the canvas.
"""
from __future__ import annotations
from typing import Callable, Iterable, Optional
from .node import NodeModel
from .port import Port, PortDirection, PortSide
from .properties import Property, PropertyBag, PropertyGroup, PropertyType
def _presentation(title: str, color: str = "#37474f") -> PropertyGroup:
return PropertyGroup("Presentation", [
Property("title", "Title", PropertyType.STRING, title),
Property("color", "Color", PropertyType.COLOR, color),
Property("notes", "Notes", PropertyType.STRING, ""),
])
class NodeTemplate:
"""Definition of a draggable node type."""
def __init__(
self,
key: str,
label: str,
group: str,
svg_name: str,
ports: Iterable[Port],
*,
width: float = 80.0,
height: float = 80.0,
default_color: str = "#37474f",
physics: Optional[Callable[[], PropertyGroup]] = None,
) -> None:
self.key = key
self.label = label
self.group = group
self.svg_name = svg_name
self.width = width
self.height = height
self.default_color = default_color
self._ports = list(ports)
self._physics = physics
def make_properties(self) -> PropertyBag:
bag = PropertyBag([_presentation(self.label, self.default_color)])
if self._physics is not None:
bag.add_group(self._physics())
return bag
def instantiate(self, node_id: str, x: float, y: float) -> NodeModel:
return NodeModel(
node_id,
self.key,
svg_name=self.svg_name,
x=x,
y=y,
width=self.width,
height=self.height,
ports=[p.clone() for p in self._ports],
properties=self.make_properties(),
)
class NodeLibrary:
"""Ordered collection of templates, grouped for the node panel."""
def __init__(self, templates: Optional[Iterable[NodeTemplate]] = None) -> None:
self._templates: dict[str, NodeTemplate] = {}
for t in templates or []:
self.add(t)
def add(self, template: NodeTemplate) -> NodeTemplate:
self._templates[template.key] = template
return template
def get(self, key: str) -> Optional[NodeTemplate]:
return self._templates.get(key)
def groups(self) -> dict[str, list[NodeTemplate]]:
out: dict[str, list[NodeTemplate]] = {}
for t in self._templates.values():
out.setdefault(t.group, []).append(t)
return out
def __iter__(self):
return iter(self._templates.values())
# -- physics group builders ----------------------------------------------
def _pump_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("head", "Rated Head", PropertyType.FLOAT, 30.0, unit="m", minimum=0),
Property("power", "Power", PropertyType.FLOAT, 5.5, unit="kW", minimum=0),
Property("status", "Status", PropertyType.ENUM, "on", options=["on", "off"]),
])
def _tank_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("volume", "Volume", PropertyType.FLOAT, 100.0, unit="m3", minimum=0),
Property("level", "Level", PropertyType.FLOAT, 50.0, unit="%", minimum=0, maximum=100),
Property("elevation", "Elevation", PropertyType.FLOAT, 0.0, unit="m"),
])
def _valve_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("diameter", "Diameter", PropertyType.CATALOGUE_ITEM, "DN100",
catalogue="pipe_classes"),
Property("state", "State", PropertyType.ENUM, "open",
options=["open", "closed", "throttled"]),
Property("opening", "Opening", PropertyType.FLOAT, 100.0, unit="%",
minimum=0, maximum=100),
])
def _source_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("supply", "Supply Rate", PropertyType.FLOAT, 120.0, unit="m3/h", minimum=0),
Property("pressure", "Pressure", PropertyType.FLOAT, 4.0, unit="bar", minimum=0),
])
def _sink_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("demand", "Demand", PropertyType.FLOAT, 40.0, unit="m3/h", minimum=0),
Property("flow_type", "Flow Type", PropertyType.ENUM, "steady",
options=["steady", "peak", "intermittent"]),
])
def _junction_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("elevation", "Elevation", PropertyType.FLOAT, 0.0, unit="m"),
])
def _transformer_physics() -> PropertyGroup:
return PropertyGroup("Physics", [
Property("rating", "Rating", PropertyType.FLOAT, 250.0, unit="kVA", minimum=0),
Property("primary_v", "Primary", PropertyType.FLOAT, 11.0, unit="kV", minimum=0),
Property("secondary_v", "Secondary", PropertyType.FLOAT, 0.4, unit="kV", minimum=0),
])
def default_library() -> NodeLibrary:
"""A starter library covering water and power network symbols."""
W = "water"
P = "power"
lib = NodeLibrary()
lib.add(NodeTemplate(
"source", "Water Source", "Sources", "source.svg",
[Port("out", W, PortSide.RIGHT, 0.5, name="Outlet", direction=PortDirection.OUTPUT)],
default_color="#1565c0", physics=_source_physics,
))
lib.add(NodeTemplate(
"tank", "Storage Tank", "Storage", "tank.svg",
[Port("in", W, PortSide.TOP, 0.35, name="Inlet", direction=PortDirection.INPUT),
Port("out", W, PortSide.BOTTOM, 0.65, name="Outlet", direction=PortDirection.OUTPUT)],
default_color="#0277bd", physics=_tank_physics,
))
lib.add(NodeTemplate(
"pump", "Pump", "Equipment", "pump.svg",
[Port("in", W, PortSide.LEFT, 0.5, name="Suction", direction=PortDirection.INPUT),
Port("out", W, PortSide.RIGHT, 0.5, name="Discharge", direction=PortDirection.OUTPUT)],
default_color="#00838f", physics=_pump_physics,
))
lib.add(NodeTemplate(
"valve", "Valve", "Equipment", "valve.svg",
[Port("in", W, PortSide.LEFT, 0.5, name="In", direction=PortDirection.INPUT),
Port("out", W, PortSide.RIGHT, 0.5, name="Out", direction=PortDirection.OUTPUT)],
width=80, height=50, default_color="#455a64", physics=_valve_physics,
))
lib.add(NodeTemplate(
"junction", "Junction", "Junctions", "junction.svg",
[Port("n", W, PortSide.TOP, 0.5, name="N"),
Port("e", W, PortSide.RIGHT, 0.5, name="E"),
Port("s", W, PortSide.BOTTOM, 0.5, name="S"),
Port("w", W, PortSide.LEFT, 0.5, name="W")],
width=50, height=50, default_color="#546e7a", physics=_junction_physics,
))
lib.add(NodeTemplate(
"consumer", "Consumer", "Sinks", "consumer.svg",
[Port("in", W, PortSide.LEFT, 0.5, name="Inlet", direction=PortDirection.INPUT)],
default_color="#5d4037", physics=_sink_physics,
))
lib.add(NodeTemplate(
"transformer", "Transformer", "Power", "transformer.svg",
[Port("hv", P, PortSide.TOP, 0.5, name="HV", direction=PortDirection.INPUT),
Port("lv", P, PortSide.BOTTOM, 0.5, name="LV", direction=PortDirection.OUTPUT)],
default_color="#2e7d32", physics=_transformer_physics,
))
lib.add(NodeTemplate(
"power_source", "Power Source", "Power", "power_source.svg",
[Port("out", P, PortSide.RIGHT, 0.5, name="Feed", direction=PortDirection.OUTPUT)],
default_color="#388e3c", physics=_source_physics,
))
return lib