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