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>
This commit is contained in:
Ilya 2026-07-02 23:13:18 +02:00
parent 9d6ceb8145
commit 62780c66be
18 changed files with 1433 additions and 0 deletions

View File

@ -0,0 +1,109 @@
"""Catalogues of reusable engineering items (pipe classes, materials, ...).
A ``CATALOGUE_ITEM`` / ``CATALOGUE_ITEMS`` property stores item id(s); the
catalogue registry resolves them to full records for display and calculation.
"""
from __future__ import annotations
from typing import Any, Iterable, Iterator, Optional
class CatalogueItem:
"""A single catalogue record: an id, a display name and arbitrary attributes."""
__slots__ = ("item_id", "name", "attributes")
def __init__(self, item_id: str, name: str, attributes: Optional[dict] = None) -> None:
self.item_id = item_id
self.name = name
self.attributes = dict(attributes or {})
def attr(self, key: str, default: Any = None) -> Any:
return self.attributes.get(key, default)
def to_dict(self) -> dict:
return {"id": self.item_id, "name": self.name, "attributes": self.attributes}
@classmethod
def from_dict(cls, data: dict) -> "CatalogueItem":
return cls(data["id"], data.get("name", data["id"]), data.get("attributes"))
def __repr__(self) -> str: # pragma: no cover
return f"CatalogueItem({self.item_id!r}, {self.name!r})"
class Catalogue:
"""A named, ordered collection of :class:`CatalogueItem`."""
def __init__(self, name: str, items: Optional[Iterable[CatalogueItem]] = None) -> None:
self.name = name
self._items: dict[str, CatalogueItem] = {}
for it in items or []:
self.add(it)
def add(self, item: CatalogueItem) -> CatalogueItem:
self._items[item.item_id] = item
return item
def get(self, item_id: str) -> Optional[CatalogueItem]:
return self._items.get(item_id)
def __iter__(self) -> Iterator[CatalogueItem]:
return iter(self._items.values())
def __len__(self) -> int:
return len(self._items)
def to_dict(self) -> dict:
return {"name": self.name, "items": [i.to_dict() for i in self._items.values()]}
@classmethod
def from_dict(cls, data: dict) -> "Catalogue":
return cls(data["name"], [CatalogueItem.from_dict(i) for i in data.get("items", [])])
class CatalogueRegistry:
"""Holds all catalogues available to a document."""
def __init__(self, catalogues: Optional[Iterable[Catalogue]] = None) -> None:
self._catalogues: dict[str, Catalogue] = {}
for c in catalogues or []:
self.register(c)
def register(self, catalogue: Catalogue) -> Catalogue:
self._catalogues[catalogue.name] = catalogue
return catalogue
def get(self, name: str) -> Optional[Catalogue]:
return self._catalogues.get(name)
def resolve(self, catalogue: str, item_id: str) -> Optional[CatalogueItem]:
cat = self._catalogues.get(catalogue)
return cat.get(item_id) if cat else None
def __iter__(self) -> Iterator[Catalogue]:
return iter(self._catalogues.values())
def to_dict(self) -> dict:
return {"catalogues": [c.to_dict() for c in self._catalogues.values()]}
@classmethod
def from_dict(cls, data: dict) -> "CatalogueRegistry":
return cls([Catalogue.from_dict(c) for c in data.get("catalogues", [])])
def default_registry() -> CatalogueRegistry:
"""A small starter registry of pipe classes and materials."""
pipe_classes = Catalogue("pipe_classes", [
CatalogueItem("DN50", "DN50", {"diameter_mm": 50, "pn": 16}),
CatalogueItem("DN100", "DN100", {"diameter_mm": 100, "pn": 16}),
CatalogueItem("DN200", "DN200", {"diameter_mm": 200, "pn": 10}),
CatalogueItem("DN300", "DN300", {"diameter_mm": 300, "pn": 10}),
])
materials = Catalogue("materials", [
CatalogueItem("steel", "Steel", {"roughness_mm": 0.045}),
CatalogueItem("pvc", "PVC", {"roughness_mm": 0.0015}),
CatalogueItem("copper", "Copper", {"roughness_mm": 0.0015}),
CatalogueItem("cast_iron", "Cast Iron", {"roughness_mm": 0.26}),
])
return CatalogueRegistry([pipe_classes, materials])

View File

@ -0,0 +1,186 @@
"""DiagramDocument: the in-memory model of a diagram plus change signals.
Uses ``QObject`` signals so the view can react to model changes, but does not
require a running ``QApplication`` for construction or signal emission (direct
connections work without an event loop), keeping it unit-test friendly.
"""
from __future__ import annotations
import json
from typing import Iterable, Iterator, Optional
from PyQt6.QtCore import QObject, pyqtSignal
from .catalogue import CatalogueRegistry, default_registry as default_catalogues
from .edge import EdgeModel
from .node import NodeModel
from .relations import RelationRegistry, default_registry as default_relations
class DiagramDocument(QObject):
"""Holds nodes and edges, assigns ids, and emits change notifications."""
node_added = pyqtSignal(str)
node_removed = pyqtSignal(str)
node_changed = pyqtSignal(str)
edge_added = pyqtSignal(str)
edge_removed = pyqtSignal(str)
edge_changed = pyqtSignal(str)
cleared = pyqtSignal()
modified = pyqtSignal()
def __init__(
self,
*,
relations: Optional[RelationRegistry] = None,
catalogues: Optional[CatalogueRegistry] = None,
parent: Optional[QObject] = None,
) -> None:
super().__init__(parent)
self.relations = relations if relations is not None else default_relations()
self.catalogues = catalogues if catalogues is not None else default_catalogues()
self._nodes: dict[str, NodeModel] = {}
self._edges: dict[str, EdgeModel] = {}
self._id_counter = 0
self.dirty = False
# -- id generation ----------------------------------------------------
def next_id(self, prefix: str) -> str:
self._id_counter += 1
return f"{prefix}{self._id_counter}"
# -- nodes ------------------------------------------------------------
def add_node(self, node: NodeModel) -> NodeModel:
if node.node_id in self._nodes:
raise ValueError(f"duplicate node id: {node.node_id}")
self._nodes[node.node_id] = node
self._mark_dirty()
self.node_added.emit(node.node_id)
return node
def remove_node(self, node_id: str) -> list[str]:
"""Remove a node and any edges attached to it. Returns removed edge ids."""
if node_id not in self._nodes:
return []
removed_edges = [e.edge_id for e in self._edges.values()
if e.source_node == node_id or e.target_node == node_id]
for eid in removed_edges:
self.remove_edge(eid)
del self._nodes[node_id]
self._mark_dirty()
self.node_removed.emit(node_id)
return removed_edges
def node(self, node_id: str) -> Optional[NodeModel]:
return self._nodes.get(node_id)
def nodes(self) -> Iterator[NodeModel]:
return iter(self._nodes.values())
def notify_node_changed(self, node_id: str) -> None:
if node_id in self._nodes:
self._mark_dirty()
self.node_changed.emit(node_id)
# -- edges ------------------------------------------------------------
def can_connect(self, src_node: str, src_port: str,
dst_node: str, dst_port: str) -> bool:
"""Whether two ports may be connected under the relation rules."""
sn, dn = self._nodes.get(src_node), self._nodes.get(dst_node)
if sn is None or dn is None:
return False
sp, dp = sn.port(src_port), dn.port(dst_port)
if sp is None or dp is None:
return False
if src_node == dst_node and src_port == dst_port:
return False
return self.relations.can_connect(sp.relation, dp.relation)
def add_edge(self, edge: EdgeModel) -> EdgeModel:
if edge.edge_id in self._edges:
raise ValueError(f"duplicate edge id: {edge.edge_id}")
if not edge.relation:
sn = self._nodes.get(edge.source_node)
sp = sn.port(edge.source_port) if sn else None
if sp is not None:
edge.relation = sp.relation
self._edges[edge.edge_id] = edge
self._mark_dirty()
self.edge_added.emit(edge.edge_id)
return edge
def remove_edge(self, edge_id: str) -> bool:
if edge_id not in self._edges:
return False
del self._edges[edge_id]
self._mark_dirty()
self.edge_removed.emit(edge_id)
return True
def edge(self, edge_id: str) -> Optional[EdgeModel]:
return self._edges.get(edge_id)
def edges(self) -> Iterator[EdgeModel]:
return iter(self._edges.values())
def edges_for_node(self, node_id: str) -> list[EdgeModel]:
return [e for e in self._edges.values()
if e.source_node == node_id or e.target_node == node_id]
def notify_edge_changed(self, edge_id: str) -> None:
if edge_id in self._edges:
self._mark_dirty()
self.edge_changed.emit(edge_id)
# -- bulk -------------------------------------------------------------
def clear(self) -> None:
self._nodes.clear()
self._edges.clear()
self._id_counter = 0
self._mark_dirty()
self.cleared.emit()
def __len__(self) -> int:
return len(self._nodes) + len(self._edges)
def _mark_dirty(self) -> None:
self.dirty = True
self.modified.emit()
# -- serialization ----------------------------------------------------
def to_dict(self) -> dict:
return {
"version": 1,
"id_counter": self._id_counter,
"nodes": [n.to_dict() for n in self._nodes.values()],
"edges": [e.to_dict() for e in self._edges.values()],
"relations": [r.to_dict() for r in self.relations],
"active_scheme": self.relations.active_scheme,
"catalogues": self.catalogues.to_dict()["catalogues"],
}
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent)
def load_dict(self, data: dict) -> None:
"""Replace document contents from a serialized dict (no signals per item)."""
self._nodes.clear()
self._edges.clear()
for nd in data.get("nodes", []):
n = NodeModel.from_dict(nd)
self._nodes[n.node_id] = n
for ed in data.get("edges", []):
e = EdgeModel.from_dict(ed)
self._edges[e.edge_id] = e
self._id_counter = data.get("id_counter", len(self._nodes) + len(self._edges))
scheme = data.get("active_scheme")
if scheme:
self.relations.set_active_scheme(scheme)
self.dirty = False
self.cleared.emit() # tells the view to rebuild from scratch
@classmethod
def from_json(cls, text: str, **kwargs) -> "DiagramDocument":
doc = cls(**kwargs)
doc.load_dict(json.loads(text))
return doc

View File

@ -0,0 +1,141 @@
"""EdgeModel: a connector between two ports, with style and properties."""
from __future__ import annotations
from enum import Enum
from typing import Optional
from .properties import PropertyBag
class EndpointDecoration(str, Enum):
"""Head/tail decorations drawn at an edge endpoint."""
NONE = "none"
ARROW = "arrow"
CIRCLE = "circle"
DIAMOND = "diamond"
BAR = "bar"
class LineStyle(str, Enum):
SOLID = "solid"
DASHED = "dashed"
DOTTED = "dotted"
DASH_DOT = "dash_dot"
class EdgeStyle:
"""Visual style of an edge."""
__slots__ = ("line_style", "width", "color", "tail", "head")
def __init__(
self,
*,
line_style: LineStyle = LineStyle.SOLID,
width: float = 2.0,
color: Optional[str] = None,
tail: EndpointDecoration = EndpointDecoration.NONE,
head: EndpointDecoration = EndpointDecoration.ARROW,
) -> None:
self.line_style = LineStyle(line_style)
self.width = float(width)
self.color = color # None => derive from relation
self.tail = EndpointDecoration(tail)
self.head = EndpointDecoration(head)
def to_dict(self) -> dict:
return {
"line_style": self.line_style.value,
"width": self.width,
"color": self.color,
"tail": self.tail.value,
"head": self.head.value,
}
@classmethod
def from_dict(cls, data: dict) -> "EdgeStyle":
return cls(
line_style=LineStyle(data.get("line_style", "solid")),
width=data.get("width", 2.0),
color=data.get("color"),
tail=EndpointDecoration(data.get("tail", "none")),
head=EndpointDecoration(data.get("head", "arrow")),
)
def clone(self) -> "EdgeStyle":
return EdgeStyle.from_dict(self.to_dict())
class EdgeModel:
"""A connector between (source_node.source_port) and (target_node.target_port).
``relation`` records the media the edge carries (usually inherited from the
connected ports) and drives default coloring. ``waypoints`` optionally pins
intermediate routing points; when empty the router computes an orthogonal path.
"""
def __init__(
self,
edge_id: str,
source_node: str,
source_port: str,
target_node: str,
target_port: str,
*,
relation: str = "",
style: Optional[EdgeStyle] = None,
properties: Optional[PropertyBag] = None,
waypoints: Optional[list[tuple[float, float]]] = None,
) -> None:
self.edge_id = edge_id
self.source_node = source_node
self.source_port = source_port
self.target_node = target_node
self.target_port = target_port
self.relation = relation
self.style = style if style is not None else EdgeStyle()
self.properties: PropertyBag = properties if properties is not None else PropertyBag()
self.waypoints: list[tuple[float, float]] = list(waypoints or [])
# Populated by the flow solver (mock calculation); not persisted.
self.flow: float = 0.0 # signed magnitude along source->target
self.flow_direction: int = 0 # +1 s->t, -1 t->s, 0 none
@property
def title(self) -> str:
return self.properties.value("title", "") or ""
def endpoints(self) -> tuple[tuple[str, str], tuple[str, str]]:
return (self.source_node, self.source_port), (self.target_node, self.target_port)
def to_dict(self) -> dict:
return {
"id": self.edge_id,
"source_node": self.source_node,
"source_port": self.source_port,
"target_node": self.target_node,
"target_port": self.target_port,
"relation": self.relation,
"style": self.style.to_dict(),
"properties": self.properties.to_dict(),
"waypoints": [list(w) for w in self.waypoints],
}
@classmethod
def from_dict(cls, data: dict) -> "EdgeModel":
return cls(
data["id"],
data["source_node"],
data["source_port"],
data["target_node"],
data["target_port"],
relation=data.get("relation", ""),
style=EdgeStyle.from_dict(data.get("style", {})),
properties=PropertyBag.from_dict(data.get("properties", {})),
waypoints=[tuple(w) for w in data.get("waypoints", [])],
)
def clone(self, new_id: str) -> "EdgeModel":
d = self.to_dict()
d["id"] = new_id
return EdgeModel.from_dict(d)

View File

@ -0,0 +1,110 @@
"""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)

View File

@ -0,0 +1,204 @@
"""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

View File

@ -0,0 +1,98 @@
"""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())

View File

@ -0,0 +1,228 @@
"""Typed, groupable properties for nodes and edges.
The model layer is intentionally UI-agnostic (no Qt imports) so it can be unit
tested without a running QApplication. Colors are stored as ``#rrggbb`` strings
and converted to ``QColor`` only in the view layer.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Callable, Iterable, Iterator, Optional
class PropertyType(str, Enum):
"""Supported property value types."""
STRING = "string"
INT = "int"
FLOAT = "float"
BOOL = "bool"
ENUM = "enum"
COLOR = "color"
CATALOGUE_ITEM = "catalogue_item" # single reference into a catalogue
CATALOGUE_ITEMS = "catalogue_items" # multiple references into a catalogue
VALUE_LIST = "value_list" # free list of scalar values
# Coercion helpers keep stored values well-typed after edits/deserialization.
def _coerce(ptype: "PropertyType", value: Any) -> Any:
if value is None:
return None
if ptype is PropertyType.INT:
return int(value)
if ptype is PropertyType.FLOAT:
return float(value)
if ptype is PropertyType.BOOL:
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
if ptype in (PropertyType.CATALOGUE_ITEMS, PropertyType.VALUE_LIST):
return list(value)
return value
class Property:
"""A single named, typed property value.
Attributes:
key: stable machine identifier (unique within its group).
label: human-readable name.
ptype: :class:`PropertyType`.
value: current value (type depends on ``ptype``).
options: allowed values for ENUM types.
catalogue: catalogue name for CATALOGUE_ITEM(S) types.
unit: optional unit suffix shown in the editor (e.g. "mm", "m3/h").
editable: if False the editor renders read-only.
minimum/maximum: optional numeric bounds.
"""
__slots__ = (
"key", "label", "ptype", "value", "options", "catalogue",
"unit", "editable", "minimum", "maximum", "on_change",
)
def __init__(
self,
key: str,
label: str,
ptype: PropertyType,
value: Any = None,
*,
options: Optional[Iterable[str]] = None,
catalogue: Optional[str] = None,
unit: str = "",
editable: bool = True,
minimum: Optional[float] = None,
maximum: Optional[float] = None,
) -> None:
self.key = key
self.label = label
self.ptype = PropertyType(ptype)
self.options = list(options) if options else []
self.catalogue = catalogue
self.unit = unit
self.editable = editable
self.minimum = minimum
self.maximum = maximum
self.on_change: Optional[Callable[["Property"], None]] = None
self.value = _coerce(self.ptype, value)
def set_value(self, value: Any) -> bool:
"""Set a coerced, bounds-clamped value. Returns True if it changed."""
new = _coerce(self.ptype, value)
if self.ptype in (PropertyType.INT, PropertyType.FLOAT) and new is not None:
if self.minimum is not None:
new = max(new, self.minimum)
if self.maximum is not None:
new = min(new, self.maximum)
if new == self.value:
return False
self.value = new
if self.on_change:
self.on_change(self)
return True
def to_dict(self) -> dict:
return {
"key": self.key,
"label": self.label,
"type": self.ptype.value,
"value": self.value,
"options": self.options,
"catalogue": self.catalogue,
"unit": self.unit,
"editable": self.editable,
"minimum": self.minimum,
"maximum": self.maximum,
}
@classmethod
def from_dict(cls, data: dict) -> "Property":
return cls(
data["key"],
data.get("label", data["key"]),
PropertyType(data["type"]),
data.get("value"),
options=data.get("options"),
catalogue=data.get("catalogue"),
unit=data.get("unit", ""),
editable=data.get("editable", True),
minimum=data.get("minimum"),
maximum=data.get("maximum"),
)
def clone(self) -> "Property":
return Property.from_dict(self.to_dict())
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"Property({self.key!r}={self.value!r}:{self.ptype.value})"
class PropertyGroup:
"""An ordered, named collection of properties (e.g. 'Geometry', 'Physics')."""
def __init__(self, name: str, properties: Optional[Iterable[Property]] = None) -> None:
self.name = name
self._props: list[Property] = list(properties or [])
def add(self, prop: Property) -> Property:
self._props.append(prop)
return prop
def get(self, key: str) -> Optional[Property]:
for p in self._props:
if p.key == key:
return p
return None
def value(self, key: str, default: Any = None) -> Any:
p = self.get(key)
return p.value if p is not None else default
def __iter__(self) -> Iterator[Property]:
return iter(self._props)
def __len__(self) -> int:
return len(self._props)
def to_dict(self) -> dict:
return {"name": self.name, "properties": [p.to_dict() for p in self._props]}
@classmethod
def from_dict(cls, data: dict) -> "PropertyGroup":
return cls(data["name"], [Property.from_dict(p) for p in data.get("properties", [])])
def clone(self) -> "PropertyGroup":
return PropertyGroup(self.name, [p.clone() for p in self._props])
class PropertyBag:
"""An ordered set of property groups with convenient key lookup.
Keys are unique across the whole bag, so ``bag.value("title")`` works
regardless of which group holds it.
"""
def __init__(self, groups: Optional[Iterable[PropertyGroup]] = None) -> None:
self._groups: list[PropertyGroup] = list(groups or [])
def add_group(self, group: PropertyGroup) -> PropertyGroup:
self._groups.append(group)
return group
def group(self, name: str) -> Optional[PropertyGroup]:
for g in self._groups:
if g.name == name:
return g
return None
def find(self, key: str) -> Optional[Property]:
for g in self._groups:
p = g.get(key)
if p is not None:
return p
return None
def value(self, key: str, default: Any = None) -> Any:
p = self.find(key)
return p.value if p is not None else default
def set_value(self, key: str, value: Any) -> bool:
p = self.find(key)
return p.set_value(value) if p is not None else False
def __iter__(self) -> Iterator[PropertyGroup]:
return iter(self._groups)
def __len__(self) -> int:
return len(self._groups)
def to_dict(self) -> dict:
return {"groups": [g.to_dict() for g in self._groups]}
@classmethod
def from_dict(cls, data: dict) -> "PropertyBag":
return cls([PropertyGroup.from_dict(g) for g in data.get("groups", [])])
def clone(self) -> "PropertyBag":
return PropertyBag([g.clone() for g in self._groups])

View File

@ -0,0 +1,135 @@
"""Port relations (media/domain a port carries) and their color schemes.
A relation like ``water`` or ``power`` defines both a palette and the set of
relations it may connect to. Ports may only be wired together when their
relations are mutually compatible.
"""
from __future__ import annotations
from typing import Iterable, Iterator, Optional
class PortRelation:
"""A connectable domain, e.g. water / power / gas.
Attributes:
key: stable identifier.
label: display name.
color: base ``#rrggbb`` color used for ports and edges of this relation.
compatible: set of relation keys this one may connect to. A relation is
always compatible with itself; ``compatible`` lists *additional* keys.
"""
__slots__ = ("key", "label", "color", "compatible")
def __init__(
self,
key: str,
label: str,
color: str,
compatible: Optional[Iterable[str]] = None,
) -> None:
self.key = key
self.label = label
self.color = color
self.compatible = set(compatible or [])
def can_connect(self, other: "PortRelation") -> bool:
if other.key == self.key:
return True
return other.key in self.compatible or self.key in other.compatible
def to_dict(self) -> dict:
return {
"key": self.key,
"label": self.label,
"color": self.color,
"compatible": sorted(self.compatible),
}
@classmethod
def from_dict(cls, data: dict) -> "PortRelation":
return cls(data["key"], data.get("label", data["key"]),
data.get("color", "#888888"), data.get("compatible"))
class RelationRegistry:
"""Registry of relations plus named color schemes.
A *color scheme* remaps relation base colors, letting the whole diagram be
re-themed (e.g. light vs. dark, or a high-contrast palette) without touching
per-item data.
"""
def __init__(self, relations: Optional[Iterable[PortRelation]] = None) -> None:
self._relations: dict[str, PortRelation] = {}
for r in relations or []:
self.register(r)
# scheme name -> {relation_key: color}
self._schemes: dict[str, dict[str, str]] = {}
self.active_scheme = "default"
def register(self, relation: PortRelation) -> PortRelation:
self._relations[relation.key] = relation
return relation
def get(self, key: str) -> Optional[PortRelation]:
return self._relations.get(key)
def __iter__(self) -> Iterator[PortRelation]:
return iter(self._relations.values())
def can_connect(self, key_a: str, key_b: str) -> bool:
a, b = self._relations.get(key_a), self._relations.get(key_b)
if a is None or b is None:
return False
return a.can_connect(b)
# -- color schemes ----------------------------------------------------
def add_scheme(self, name: str, colors: dict[str, str]) -> None:
self._schemes[name] = dict(colors)
def scheme_names(self) -> list[str]:
return ["default", *[n for n in self._schemes if n != "default"]]
def set_active_scheme(self, name: str) -> None:
self.active_scheme = name
def color(self, relation_key: str) -> str:
"""Resolve the color for a relation under the active scheme."""
scheme = self._schemes.get(self.active_scheme, {})
if relation_key in scheme:
return scheme[relation_key]
rel = self._relations.get(relation_key)
return rel.color if rel else "#888888"
def default_registry() -> RelationRegistry:
"""Starter relations covering common utility networks."""
reg = RelationRegistry([
PortRelation("water", "Water", "#1e88e5"),
PortRelation("hot_water", "Hot Water", "#e53935", compatible=["water"]),
PortRelation("gas", "Gas", "#fdd835"),
PortRelation("power", "Power", "#43a047"),
PortRelation("signal", "Signal", "#8e24aa"),
PortRelation("sewage", "Sewage", "#6d4c41"),
])
# A high-contrast alternative scheme.
reg.add_scheme("high_contrast", {
"water": "#0000ff",
"hot_water": "#ff0000",
"gas": "#ffcc00",
"power": "#00cc00",
"signal": "#cc00cc",
"sewage": "#663300",
})
# A muted / dark-friendly scheme.
reg.add_scheme("muted", {
"water": "#6fa8dc",
"hot_water": "#e06666",
"gas": "#ffd966",
"power": "#93c47d",
"signal": "#b4a7d6",
"sewage": "#a67c52",
})
return reg

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M22 45 L50 20 L78 45 L78 82 L22 82 Z" fill="#efebe9" stroke="#5d4037" stroke-width="4"/>
<rect x="42" y="60" width="16" height="22" fill="#5d4037"/>
<line x1="6" y1="55" x2="22" y2="55" stroke="#5d4037" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<line x1="50" y1="8" x2="50" y2="92" stroke="#546e7a" stroke-width="6"/>
<line x1="8" y1="50" x2="92" y2="50" stroke="#546e7a" stroke-width="6"/>
<circle cx="50" cy="50" r="12" fill="#546e7a"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="46" cy="50" r="38" fill="#e8f5e9" stroke="#388e3c" stroke-width="4"/>
<path d="M48 18 L30 54 L46 54 L40 82 L64 44 L48 44 Z" fill="#388e3c"/>
<rect x="84" y="45" width="14" height="10" fill="#388e3c"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="34" fill="#e0f7fa" stroke="#00838f" stroke-width="4"/>
<path d="M50 50 L50 20 A30 30 0 0 1 76 65 Z" fill="#00838f" opacity="0.85"/>
<path d="M50 50 L24 65 A30 30 0 0 1 50 20 Z" fill="#00838f" opacity="0.55"/>
<circle cx="50" cy="50" r="6" fill="#004d55"/>
<line x1="6" y1="50" x2="16" y2="50" stroke="#00838f" stroke-width="5"/>
<line x1="84" y1="50" x2="94" y2="50" stroke="#00838f" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="6" y="20" width="70" height="60" rx="6" fill="#e3f2fd" stroke="#1565c0" stroke-width="4"/>
<path d="M6 55 q17 -12 35 0 t35 0" fill="none" stroke="#1565c0" stroke-width="4"/>
<path d="M6 68 q17 -12 35 0 t35 0" fill="none" stroke="#1565c0" stroke-width="3"/>
<rect x="76" y="45" width="18" height="10" fill="#1565c0"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="20" y="10" width="60" height="80" rx="10" fill="#e1f5fe" stroke="#0277bd" stroke-width="4"/>
<rect x="20" y="55" width="60" height="35" rx="6" fill="#4fc3f7" stroke="#0277bd" stroke-width="0"/>
<rect x="20" y="10" width="60" height="80" rx="10" fill="none" stroke="#0277bd" stroke-width="4"/>
<line x1="35" y1="6" x2="35" y2="10" stroke="#0277bd" stroke-width="5"/>
<line x1="65" y1="90" x2="65" y2="96" stroke="#0277bd" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="34" r="22" fill="none" stroke="#2e7d32" stroke-width="5"/>
<circle cx="50" cy="66" r="22" fill="none" stroke="#2e7d32" stroke-width="5"/>
<line x1="50" y1="4" x2="50" y2="12" stroke="#2e7d32" stroke-width="5"/>
<line x1="50" y1="88" x2="50" y2="96" stroke="#2e7d32" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M18 25 L50 50 L18 75 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="4"/>
<path d="M82 25 L50 50 L82 75 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="4"/>
<line x1="50" y1="50" x2="50" y2="22" stroke="#455a64" stroke-width="4"/>
<rect x="38" y="12" width="24" height="10" rx="3" fill="#455a64"/>
<line x1="4" y1="50" x2="18" y2="50" stroke="#455a64" stroke-width="5"/>
<line x1="82" y1="50" x2="96" y2="50" stroke="#455a64" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 538 B

80
tests/test_document.py Normal file
View File

@ -0,0 +1,80 @@
"""DiagramDocument tests: add/remove, connect rules, signals, serialization."""
import pytest
from pipeline_editor.model.document import DiagramDocument
from pipeline_editor.model.edge import EdgeModel
from pipeline_editor.model.node_library import default_library
@pytest.fixture
def doc(qapp):
return DiagramDocument()
@pytest.fixture
def lib():
return default_library()
def _add(doc, lib, key, x, y):
node = lib.get(key).instantiate(doc.next_id("n"), x, y)
return doc.add_node(node)
def test_add_and_signals(doc, lib):
events = []
doc.node_added.connect(lambda nid: events.append(("node", nid)))
doc.edge_added.connect(lambda eid: events.append(("edge", eid)))
a = _add(doc, lib, "source", 0, 0)
b = _add(doc, lib, "pump", 200, 0)
edge = EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in")
doc.add_edge(edge)
assert ("node", a.node_id) in events
assert ("edge", edge.edge_id) in events
assert doc.dirty is True
def test_connect_rules(doc, lib):
a = _add(doc, lib, "source", 0, 0) # water out
b = _add(doc, lib, "pump", 200, 0) # water in
t = _add(doc, lib, "transformer", 0, 200) # power ports
assert doc.can_connect(a.node_id, "out", b.node_id, "in") is True
assert doc.can_connect(a.node_id, "out", t.node_id, "hv") is False # water vs power
assert doc.can_connect(a.node_id, "out", a.node_id, "out") is False # same port
def test_remove_node_cascades_edges(doc, lib):
a = _add(doc, lib, "source", 0, 0)
b = _add(doc, lib, "pump", 200, 0)
e = doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
removed = doc.remove_node(a.node_id)
assert e.edge_id in removed
assert doc.edge(e.edge_id) is None
assert doc.node(a.node_id) is None
def test_edge_inherits_relation(doc, lib):
a = _add(doc, lib, "source", 0, 0)
b = _add(doc, lib, "pump", 200, 0)
e = doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
assert e.relation == "water"
def test_serialization_roundtrip(doc, lib):
a = _add(doc, lib, "source", 10, 20)
b = _add(doc, lib, "consumer", 300, 20)
a.properties.set_value("supply", 200)
doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
text = doc.to_json()
doc2 = DiagramDocument.from_json(text)
assert list(n.node_id for n in doc2.nodes()) == [a.node_id, b.node_id]
ra = doc2.node(a.node_id)
assert ra.x == 10 and ra.properties.value("supply") == 200
assert sum(1 for _ in doc2.edges()) == 1
def test_clear(doc, lib):
_add(doc, lib, "source", 0, 0)
doc.clear()
assert len(doc) == 0

92
tests/test_model.py Normal file
View File

@ -0,0 +1,92 @@
"""Domain-model tests: properties, catalogue, relations, ports, serialization."""
from pipeline_editor.model.properties import (
Property, PropertyBag, PropertyGroup, PropertyType,
)
from pipeline_editor.model.catalogue import default_registry as default_catalogues
from pipeline_editor.model.relations import default_registry as default_relations
from pipeline_editor.model.port import Port, PortSide, PortDirection
from pipeline_editor.model.node_library import default_library
def test_property_coercion_and_bounds():
p = Property("d", "Diameter", PropertyType.FLOAT, "50", minimum=0, maximum=100)
assert p.value == 50.0 and isinstance(p.value, float)
assert p.set_value(150) is True
assert p.value == 100.0 # clamped to max
assert p.set_value(100) is False # unchanged
assert p.set_value(-5) is True and p.value == 0.0 # clamped to min
def test_property_on_change_callback():
seen = []
p = Property("t", "Title", PropertyType.STRING, "a")
p.on_change = lambda prop: seen.append(prop.value)
p.set_value("b")
assert seen == ["b"]
def test_property_bag_lookup_and_roundtrip():
bag = PropertyBag([
PropertyGroup("Presentation", [Property("title", "Title", PropertyType.STRING, "N1")]),
PropertyGroup("Physics", [Property("len", "Length", PropertyType.FLOAT, 12.5, unit="m")]),
])
assert bag.value("title") == "N1"
assert bag.set_value("len", 20) is True
restored = PropertyBag.from_dict(bag.to_dict())
assert restored.value("len") == 20.0
assert restored.group("Physics").get("len").unit == "m"
def test_all_property_types_roundtrip():
bag = PropertyBag([PropertyGroup("G", [
Property("s", "S", PropertyType.STRING, "x"),
Property("i", "I", PropertyType.INT, 3),
Property("f", "F", PropertyType.FLOAT, 1.5),
Property("b", "B", PropertyType.BOOL, True),
Property("e", "E", PropertyType.ENUM, "a", options=["a", "b"]),
Property("c", "C", PropertyType.COLOR, "#ff0000"),
Property("ci", "CI", PropertyType.CATALOGUE_ITEM, "DN50", catalogue="pipe_classes"),
Property("cis", "CIS", PropertyType.CATALOGUE_ITEMS, ["DN50", "DN100"], catalogue="pipe_classes"),
Property("vl", "VL", PropertyType.VALUE_LIST, [1, 2, 3]),
])])
r = PropertyBag.from_dict(bag.to_dict())
assert r.value("i") == 3 and r.value("b") is True
assert r.value("cis") == ["DN50", "DN100"]
assert r.value("vl") == [1, 2, 3]
def test_catalogue_resolution():
reg = default_catalogues()
item = reg.resolve("pipe_classes", "DN100")
assert item is not None and item.attr("diameter_mm") == 100
assert reg.resolve("pipe_classes", "NOPE") is None
def test_relation_compatibility_and_color_scheme():
reg = default_relations()
assert reg.can_connect("water", "water") is True
assert reg.can_connect("water", "hot_water") is True # hot_water compatible with water
assert reg.can_connect("water", "power") is False
# default color, then switch scheme
assert reg.color("water") == "#1e88e5"
reg.set_active_scheme("high_contrast")
assert reg.color("water") == "#0000ff"
def test_port_geometry():
p = Port("out", "water", PortSide.RIGHT, 0.5, direction=PortDirection.OUTPUT)
assert p.local_point(80, 40) == (80, 20)
assert p.normal() == (1.0, 0.0)
top = Port("t", "water", PortSide.TOP, 0.25)
assert top.local_point(100, 100) == (25, 0)
def test_library_instantiation():
lib = default_library()
tmpl = lib.get("pump")
node = tmpl.instantiate("n1", 10, 20)
assert node.title == "Pump"
assert node.properties.group("Physics").get("head").value == 30.0
assert {p.port_id for p in node.ports} == {"in", "out"}
# groups exposed for the panel
assert "Equipment" in lib.groups()