diff --git a/pipeline_editor/model/catalogue.py b/pipeline_editor/model/catalogue.py
new file mode 100644
index 0000000..c71a050
--- /dev/null
+++ b/pipeline_editor/model/catalogue.py
@@ -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])
diff --git a/pipeline_editor/model/document.py b/pipeline_editor/model/document.py
new file mode 100644
index 0000000..ddb29d8
--- /dev/null
+++ b/pipeline_editor/model/document.py
@@ -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
diff --git a/pipeline_editor/model/edge.py b/pipeline_editor/model/edge.py
new file mode 100644
index 0000000..273eb4c
--- /dev/null
+++ b/pipeline_editor/model/edge.py
@@ -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)
diff --git a/pipeline_editor/model/node.py b/pipeline_editor/model/node.py
new file mode 100644
index 0000000..5ae5a9b
--- /dev/null
+++ b/pipeline_editor/model/node.py
@@ -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)
diff --git a/pipeline_editor/model/node_library.py b/pipeline_editor/model/node_library.py
new file mode 100644
index 0000000..d81a8ef
--- /dev/null
+++ b/pipeline_editor/model/node_library.py
@@ -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
diff --git a/pipeline_editor/model/port.py b/pipeline_editor/model/port.py
new file mode 100644
index 0000000..570e795
--- /dev/null
+++ b/pipeline_editor/model/port.py
@@ -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())
diff --git a/pipeline_editor/model/properties.py b/pipeline_editor/model/properties.py
new file mode 100644
index 0000000..86fa5fe
--- /dev/null
+++ b/pipeline_editor/model/properties.py
@@ -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])
diff --git a/pipeline_editor/model/relations.py b/pipeline_editor/model/relations.py
new file mode 100644
index 0000000..3092921
--- /dev/null
+++ b/pipeline_editor/model/relations.py
@@ -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
diff --git a/pipeline_editor/resources/nodes/consumer.svg b/pipeline_editor/resources/nodes/consumer.svg
new file mode 100644
index 0000000..7225b1e
--- /dev/null
+++ b/pipeline_editor/resources/nodes/consumer.svg
@@ -0,0 +1,5 @@
+
diff --git a/pipeline_editor/resources/nodes/junction.svg b/pipeline_editor/resources/nodes/junction.svg
new file mode 100644
index 0000000..59fb368
--- /dev/null
+++ b/pipeline_editor/resources/nodes/junction.svg
@@ -0,0 +1,5 @@
+
diff --git a/pipeline_editor/resources/nodes/power_source.svg b/pipeline_editor/resources/nodes/power_source.svg
new file mode 100644
index 0000000..7510cae
--- /dev/null
+++ b/pipeline_editor/resources/nodes/power_source.svg
@@ -0,0 +1,5 @@
+
diff --git a/pipeline_editor/resources/nodes/pump.svg b/pipeline_editor/resources/nodes/pump.svg
new file mode 100644
index 0000000..ec1a378
--- /dev/null
+++ b/pipeline_editor/resources/nodes/pump.svg
@@ -0,0 +1,8 @@
+
diff --git a/pipeline_editor/resources/nodes/source.svg b/pipeline_editor/resources/nodes/source.svg
new file mode 100644
index 0000000..9448ae5
--- /dev/null
+++ b/pipeline_editor/resources/nodes/source.svg
@@ -0,0 +1,6 @@
+
diff --git a/pipeline_editor/resources/nodes/tank.svg b/pipeline_editor/resources/nodes/tank.svg
new file mode 100644
index 0000000..a6a7079
--- /dev/null
+++ b/pipeline_editor/resources/nodes/tank.svg
@@ -0,0 +1,7 @@
+
diff --git a/pipeline_editor/resources/nodes/transformer.svg b/pipeline_editor/resources/nodes/transformer.svg
new file mode 100644
index 0000000..6e6cc29
--- /dev/null
+++ b/pipeline_editor/resources/nodes/transformer.svg
@@ -0,0 +1,6 @@
+
diff --git a/pipeline_editor/resources/nodes/valve.svg b/pipeline_editor/resources/nodes/valve.svg
new file mode 100644
index 0000000..0f3501e
--- /dev/null
+++ b/pipeline_editor/resources/nodes/valve.svg
@@ -0,0 +1,8 @@
+
diff --git a/tests/test_document.py b/tests/test_document.py
new file mode 100644
index 0000000..397c0ca
--- /dev/null
+++ b/tests/test_document.py
@@ -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
diff --git a/tests/test_model.py b/tests/test_model.py
new file mode 100644
index 0000000..b67630c
--- /dev/null
+++ b/tests/test_model.py
@@ -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()