feat(model): domain model, node library, and SVG symbols
Add UI-agnostic model layer: typed grouped properties (string/int/float/ bool/enum/color/catalogue item(s)/value list), catalogue registry, port relations with color schemes and compatibility rules, ports, node/edge models, and DiagramDocument with change signals and JSON round-trip. Include a starter node library (source/tank/pump/valve/junction/consumer/ transformer) with matching SVG symbols, plus model+document tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109
pipeline_editor/model/catalogue.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""Catalogues of reusable engineering items (pipe classes, materials, ...).
|
||||
|
||||
A ``CATALOGUE_ITEM`` / ``CATALOGUE_ITEMS`` property stores item id(s); the
|
||||
catalogue registry resolves them to full records for display and calculation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, Iterator, Optional
|
||||
|
||||
|
||||
class CatalogueItem:
|
||||
"""A single catalogue record: an id, a display name and arbitrary attributes."""
|
||||
|
||||
__slots__ = ("item_id", "name", "attributes")
|
||||
|
||||
def __init__(self, item_id: str, name: str, attributes: Optional[dict] = None) -> None:
|
||||
self.item_id = item_id
|
||||
self.name = name
|
||||
self.attributes = dict(attributes or {})
|
||||
|
||||
def attr(self, key: str, default: Any = None) -> Any:
|
||||
return self.attributes.get(key, default)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"id": self.item_id, "name": self.name, "attributes": self.attributes}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CatalogueItem":
|
||||
return cls(data["id"], data.get("name", data["id"]), data.get("attributes"))
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"CatalogueItem({self.item_id!r}, {self.name!r})"
|
||||
|
||||
|
||||
class Catalogue:
|
||||
"""A named, ordered collection of :class:`CatalogueItem`."""
|
||||
|
||||
def __init__(self, name: str, items: Optional[Iterable[CatalogueItem]] = None) -> None:
|
||||
self.name = name
|
||||
self._items: dict[str, CatalogueItem] = {}
|
||||
for it in items or []:
|
||||
self.add(it)
|
||||
|
||||
def add(self, item: CatalogueItem) -> CatalogueItem:
|
||||
self._items[item.item_id] = item
|
||||
return item
|
||||
|
||||
def get(self, item_id: str) -> Optional[CatalogueItem]:
|
||||
return self._items.get(item_id)
|
||||
|
||||
def __iter__(self) -> Iterator[CatalogueItem]:
|
||||
return iter(self._items.values())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"name": self.name, "items": [i.to_dict() for i in self._items.values()]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Catalogue":
|
||||
return cls(data["name"], [CatalogueItem.from_dict(i) for i in data.get("items", [])])
|
||||
|
||||
|
||||
class CatalogueRegistry:
|
||||
"""Holds all catalogues available to a document."""
|
||||
|
||||
def __init__(self, catalogues: Optional[Iterable[Catalogue]] = None) -> None:
|
||||
self._catalogues: dict[str, Catalogue] = {}
|
||||
for c in catalogues or []:
|
||||
self.register(c)
|
||||
|
||||
def register(self, catalogue: Catalogue) -> Catalogue:
|
||||
self._catalogues[catalogue.name] = catalogue
|
||||
return catalogue
|
||||
|
||||
def get(self, name: str) -> Optional[Catalogue]:
|
||||
return self._catalogues.get(name)
|
||||
|
||||
def resolve(self, catalogue: str, item_id: str) -> Optional[CatalogueItem]:
|
||||
cat = self._catalogues.get(catalogue)
|
||||
return cat.get(item_id) if cat else None
|
||||
|
||||
def __iter__(self) -> Iterator[Catalogue]:
|
||||
return iter(self._catalogues.values())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"catalogues": [c.to_dict() for c in self._catalogues.values()]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "CatalogueRegistry":
|
||||
return cls([Catalogue.from_dict(c) for c in data.get("catalogues", [])])
|
||||
|
||||
|
||||
def default_registry() -> CatalogueRegistry:
|
||||
"""A small starter registry of pipe classes and materials."""
|
||||
pipe_classes = Catalogue("pipe_classes", [
|
||||
CatalogueItem("DN50", "DN50", {"diameter_mm": 50, "pn": 16}),
|
||||
CatalogueItem("DN100", "DN100", {"diameter_mm": 100, "pn": 16}),
|
||||
CatalogueItem("DN200", "DN200", {"diameter_mm": 200, "pn": 10}),
|
||||
CatalogueItem("DN300", "DN300", {"diameter_mm": 300, "pn": 10}),
|
||||
])
|
||||
materials = Catalogue("materials", [
|
||||
CatalogueItem("steel", "Steel", {"roughness_mm": 0.045}),
|
||||
CatalogueItem("pvc", "PVC", {"roughness_mm": 0.0015}),
|
||||
CatalogueItem("copper", "Copper", {"roughness_mm": 0.0015}),
|
||||
CatalogueItem("cast_iron", "Cast Iron", {"roughness_mm": 0.26}),
|
||||
])
|
||||
return CatalogueRegistry([pipe_classes, materials])
|
||||
186
pipeline_editor/model/document.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""DiagramDocument: the in-memory model of a diagram plus change signals.
|
||||
|
||||
Uses ``QObject`` signals so the view can react to model changes, but does not
|
||||
require a running ``QApplication`` for construction or signal emission (direct
|
||||
connections work without an event loop), keeping it unit-test friendly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from .catalogue import CatalogueRegistry, default_registry as default_catalogues
|
||||
from .edge import EdgeModel
|
||||
from .node import NodeModel
|
||||
from .relations import RelationRegistry, default_registry as default_relations
|
||||
|
||||
|
||||
class DiagramDocument(QObject):
|
||||
"""Holds nodes and edges, assigns ids, and emits change notifications."""
|
||||
|
||||
node_added = pyqtSignal(str)
|
||||
node_removed = pyqtSignal(str)
|
||||
node_changed = pyqtSignal(str)
|
||||
edge_added = pyqtSignal(str)
|
||||
edge_removed = pyqtSignal(str)
|
||||
edge_changed = pyqtSignal(str)
|
||||
cleared = pyqtSignal()
|
||||
modified = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
relations: Optional[RelationRegistry] = None,
|
||||
catalogues: Optional[CatalogueRegistry] = None,
|
||||
parent: Optional[QObject] = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.relations = relations if relations is not None else default_relations()
|
||||
self.catalogues = catalogues if catalogues is not None else default_catalogues()
|
||||
self._nodes: dict[str, NodeModel] = {}
|
||||
self._edges: dict[str, EdgeModel] = {}
|
||||
self._id_counter = 0
|
||||
self.dirty = False
|
||||
|
||||
# -- id generation ----------------------------------------------------
|
||||
def next_id(self, prefix: str) -> str:
|
||||
self._id_counter += 1
|
||||
return f"{prefix}{self._id_counter}"
|
||||
|
||||
# -- nodes ------------------------------------------------------------
|
||||
def add_node(self, node: NodeModel) -> NodeModel:
|
||||
if node.node_id in self._nodes:
|
||||
raise ValueError(f"duplicate node id: {node.node_id}")
|
||||
self._nodes[node.node_id] = node
|
||||
self._mark_dirty()
|
||||
self.node_added.emit(node.node_id)
|
||||
return node
|
||||
|
||||
def remove_node(self, node_id: str) -> list[str]:
|
||||
"""Remove a node and any edges attached to it. Returns removed edge ids."""
|
||||
if node_id not in self._nodes:
|
||||
return []
|
||||
removed_edges = [e.edge_id for e in self._edges.values()
|
||||
if e.source_node == node_id or e.target_node == node_id]
|
||||
for eid in removed_edges:
|
||||
self.remove_edge(eid)
|
||||
del self._nodes[node_id]
|
||||
self._mark_dirty()
|
||||
self.node_removed.emit(node_id)
|
||||
return removed_edges
|
||||
|
||||
def node(self, node_id: str) -> Optional[NodeModel]:
|
||||
return self._nodes.get(node_id)
|
||||
|
||||
def nodes(self) -> Iterator[NodeModel]:
|
||||
return iter(self._nodes.values())
|
||||
|
||||
def notify_node_changed(self, node_id: str) -> None:
|
||||
if node_id in self._nodes:
|
||||
self._mark_dirty()
|
||||
self.node_changed.emit(node_id)
|
||||
|
||||
# -- edges ------------------------------------------------------------
|
||||
def can_connect(self, src_node: str, src_port: str,
|
||||
dst_node: str, dst_port: str) -> bool:
|
||||
"""Whether two ports may be connected under the relation rules."""
|
||||
sn, dn = self._nodes.get(src_node), self._nodes.get(dst_node)
|
||||
if sn is None or dn is None:
|
||||
return False
|
||||
sp, dp = sn.port(src_port), dn.port(dst_port)
|
||||
if sp is None or dp is None:
|
||||
return False
|
||||
if src_node == dst_node and src_port == dst_port:
|
||||
return False
|
||||
return self.relations.can_connect(sp.relation, dp.relation)
|
||||
|
||||
def add_edge(self, edge: EdgeModel) -> EdgeModel:
|
||||
if edge.edge_id in self._edges:
|
||||
raise ValueError(f"duplicate edge id: {edge.edge_id}")
|
||||
if not edge.relation:
|
||||
sn = self._nodes.get(edge.source_node)
|
||||
sp = sn.port(edge.source_port) if sn else None
|
||||
if sp is not None:
|
||||
edge.relation = sp.relation
|
||||
self._edges[edge.edge_id] = edge
|
||||
self._mark_dirty()
|
||||
self.edge_added.emit(edge.edge_id)
|
||||
return edge
|
||||
|
||||
def remove_edge(self, edge_id: str) -> bool:
|
||||
if edge_id not in self._edges:
|
||||
return False
|
||||
del self._edges[edge_id]
|
||||
self._mark_dirty()
|
||||
self.edge_removed.emit(edge_id)
|
||||
return True
|
||||
|
||||
def edge(self, edge_id: str) -> Optional[EdgeModel]:
|
||||
return self._edges.get(edge_id)
|
||||
|
||||
def edges(self) -> Iterator[EdgeModel]:
|
||||
return iter(self._edges.values())
|
||||
|
||||
def edges_for_node(self, node_id: str) -> list[EdgeModel]:
|
||||
return [e for e in self._edges.values()
|
||||
if e.source_node == node_id or e.target_node == node_id]
|
||||
|
||||
def notify_edge_changed(self, edge_id: str) -> None:
|
||||
if edge_id in self._edges:
|
||||
self._mark_dirty()
|
||||
self.edge_changed.emit(edge_id)
|
||||
|
||||
# -- bulk -------------------------------------------------------------
|
||||
def clear(self) -> None:
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
self._id_counter = 0
|
||||
self._mark_dirty()
|
||||
self.cleared.emit()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._nodes) + len(self._edges)
|
||||
|
||||
def _mark_dirty(self) -> None:
|
||||
self.dirty = True
|
||||
self.modified.emit()
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"id_counter": self._id_counter,
|
||||
"nodes": [n.to_dict() for n in self._nodes.values()],
|
||||
"edges": [e.to_dict() for e in self._edges.values()],
|
||||
"relations": [r.to_dict() for r in self.relations],
|
||||
"active_scheme": self.relations.active_scheme,
|
||||
"catalogues": self.catalogues.to_dict()["catalogues"],
|
||||
}
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
return json.dumps(self.to_dict(), indent=indent)
|
||||
|
||||
def load_dict(self, data: dict) -> None:
|
||||
"""Replace document contents from a serialized dict (no signals per item)."""
|
||||
self._nodes.clear()
|
||||
self._edges.clear()
|
||||
for nd in data.get("nodes", []):
|
||||
n = NodeModel.from_dict(nd)
|
||||
self._nodes[n.node_id] = n
|
||||
for ed in data.get("edges", []):
|
||||
e = EdgeModel.from_dict(ed)
|
||||
self._edges[e.edge_id] = e
|
||||
self._id_counter = data.get("id_counter", len(self._nodes) + len(self._edges))
|
||||
scheme = data.get("active_scheme")
|
||||
if scheme:
|
||||
self.relations.set_active_scheme(scheme)
|
||||
self.dirty = False
|
||||
self.cleared.emit() # tells the view to rebuild from scratch
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, text: str, **kwargs) -> "DiagramDocument":
|
||||
doc = cls(**kwargs)
|
||||
doc.load_dict(json.loads(text))
|
||||
return doc
|
||||
141
pipeline_editor/model/edge.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""EdgeModel: a connector between two ports, with style and properties."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from .properties import PropertyBag
|
||||
|
||||
|
||||
class EndpointDecoration(str, Enum):
|
||||
"""Head/tail decorations drawn at an edge endpoint."""
|
||||
|
||||
NONE = "none"
|
||||
ARROW = "arrow"
|
||||
CIRCLE = "circle"
|
||||
DIAMOND = "diamond"
|
||||
BAR = "bar"
|
||||
|
||||
|
||||
class LineStyle(str, Enum):
|
||||
SOLID = "solid"
|
||||
DASHED = "dashed"
|
||||
DOTTED = "dotted"
|
||||
DASH_DOT = "dash_dot"
|
||||
|
||||
|
||||
class EdgeStyle:
|
||||
"""Visual style of an edge."""
|
||||
|
||||
__slots__ = ("line_style", "width", "color", "tail", "head")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
line_style: LineStyle = LineStyle.SOLID,
|
||||
width: float = 2.0,
|
||||
color: Optional[str] = None,
|
||||
tail: EndpointDecoration = EndpointDecoration.NONE,
|
||||
head: EndpointDecoration = EndpointDecoration.ARROW,
|
||||
) -> None:
|
||||
self.line_style = LineStyle(line_style)
|
||||
self.width = float(width)
|
||||
self.color = color # None => derive from relation
|
||||
self.tail = EndpointDecoration(tail)
|
||||
self.head = EndpointDecoration(head)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"line_style": self.line_style.value,
|
||||
"width": self.width,
|
||||
"color": self.color,
|
||||
"tail": self.tail.value,
|
||||
"head": self.head.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "EdgeStyle":
|
||||
return cls(
|
||||
line_style=LineStyle(data.get("line_style", "solid")),
|
||||
width=data.get("width", 2.0),
|
||||
color=data.get("color"),
|
||||
tail=EndpointDecoration(data.get("tail", "none")),
|
||||
head=EndpointDecoration(data.get("head", "arrow")),
|
||||
)
|
||||
|
||||
def clone(self) -> "EdgeStyle":
|
||||
return EdgeStyle.from_dict(self.to_dict())
|
||||
|
||||
|
||||
class EdgeModel:
|
||||
"""A connector between (source_node.source_port) and (target_node.target_port).
|
||||
|
||||
``relation`` records the media the edge carries (usually inherited from the
|
||||
connected ports) and drives default coloring. ``waypoints`` optionally pins
|
||||
intermediate routing points; when empty the router computes an orthogonal path.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
edge_id: str,
|
||||
source_node: str,
|
||||
source_port: str,
|
||||
target_node: str,
|
||||
target_port: str,
|
||||
*,
|
||||
relation: str = "",
|
||||
style: Optional[EdgeStyle] = None,
|
||||
properties: Optional[PropertyBag] = None,
|
||||
waypoints: Optional[list[tuple[float, float]]] = None,
|
||||
) -> None:
|
||||
self.edge_id = edge_id
|
||||
self.source_node = source_node
|
||||
self.source_port = source_port
|
||||
self.target_node = target_node
|
||||
self.target_port = target_port
|
||||
self.relation = relation
|
||||
self.style = style if style is not None else EdgeStyle()
|
||||
self.properties: PropertyBag = properties if properties is not None else PropertyBag()
|
||||
self.waypoints: list[tuple[float, float]] = list(waypoints or [])
|
||||
# Populated by the flow solver (mock calculation); not persisted.
|
||||
self.flow: float = 0.0 # signed magnitude along source->target
|
||||
self.flow_direction: int = 0 # +1 s->t, -1 t->s, 0 none
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.properties.value("title", "") or ""
|
||||
|
||||
def endpoints(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||
return (self.source_node, self.source_port), (self.target_node, self.target_port)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.edge_id,
|
||||
"source_node": self.source_node,
|
||||
"source_port": self.source_port,
|
||||
"target_node": self.target_node,
|
||||
"target_port": self.target_port,
|
||||
"relation": self.relation,
|
||||
"style": self.style.to_dict(),
|
||||
"properties": self.properties.to_dict(),
|
||||
"waypoints": [list(w) for w in self.waypoints],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "EdgeModel":
|
||||
return cls(
|
||||
data["id"],
|
||||
data["source_node"],
|
||||
data["source_port"],
|
||||
data["target_node"],
|
||||
data["target_port"],
|
||||
relation=data.get("relation", ""),
|
||||
style=EdgeStyle.from_dict(data.get("style", {})),
|
||||
properties=PropertyBag.from_dict(data.get("properties", {})),
|
||||
waypoints=[tuple(w) for w in data.get("waypoints", [])],
|
||||
)
|
||||
|
||||
def clone(self, new_id: str) -> "EdgeModel":
|
||||
d = self.to_dict()
|
||||
d["id"] = new_id
|
||||
return EdgeModel.from_dict(d)
|
||||
110
pipeline_editor/model/node.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""NodeModel: a placed diagram node with geometry, ports and properties."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from .port import Port
|
||||
from .properties import PropertyBag
|
||||
|
||||
|
||||
class NodeModel:
|
||||
"""A node instance placed on the canvas.
|
||||
|
||||
Geometry (``x``, ``y``, ``width``, ``height``, ``rotation``) are first-class
|
||||
attributes used by the view. Presentation (title, color, ...) and physics
|
||||
properties live in :attr:`properties`. The display title is sourced from the
|
||||
``title`` property so there is a single source of truth.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
node_id: str,
|
||||
template_key: str,
|
||||
*,
|
||||
svg_name: str = "",
|
||||
x: float = 0.0,
|
||||
y: float = 0.0,
|
||||
width: float = 80.0,
|
||||
height: float = 80.0,
|
||||
rotation: float = 0.0,
|
||||
ports: Optional[Iterable[Port]] = None,
|
||||
properties: Optional[PropertyBag] = None,
|
||||
) -> None:
|
||||
self.node_id = node_id
|
||||
self.template_key = template_key
|
||||
self.svg_name = svg_name
|
||||
self.x = float(x)
|
||||
self.y = float(y)
|
||||
self.width = float(width)
|
||||
self.height = float(height)
|
||||
self.rotation = float(rotation)
|
||||
self.ports: list[Port] = list(ports or [])
|
||||
self.properties: PropertyBag = properties if properties is not None else PropertyBag()
|
||||
|
||||
# -- convenience ------------------------------------------------------
|
||||
@property
|
||||
def title(self) -> str:
|
||||
return self.properties.value("title", "") or ""
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str) -> None:
|
||||
if not self.properties.set_value("title", value):
|
||||
# Ensure the property exists if it was missing.
|
||||
from .properties import Property, PropertyGroup, PropertyType
|
||||
grp = self.properties.group("Presentation")
|
||||
if grp is None:
|
||||
grp = self.properties.add_group(PropertyGroup("Presentation"))
|
||||
if grp.get("title") is None:
|
||||
grp.add(Property("title", "Title", PropertyType.STRING, value))
|
||||
|
||||
def port(self, port_id: str) -> Optional[Port]:
|
||||
for p in self.ports:
|
||||
if p.port_id == port_id:
|
||||
return p
|
||||
return None
|
||||
|
||||
def port_scene_point(self, port_id: str) -> Optional[tuple[float, float]]:
|
||||
"""Absolute scene coordinates of a port (ignores rotation for routing)."""
|
||||
p = self.port(port_id)
|
||||
if p is None:
|
||||
return None
|
||||
lx, ly = p.local_point(self.width, self.height)
|
||||
return self.x + lx, self.y + ly
|
||||
|
||||
def center(self) -> tuple[float, float]:
|
||||
return self.x + self.width / 2.0, self.y + self.height / 2.0
|
||||
|
||||
# -- serialization ----------------------------------------------------
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.node_id,
|
||||
"template": self.template_key,
|
||||
"svg": self.svg_name,
|
||||
"x": self.x,
|
||||
"y": self.y,
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"rotation": self.rotation,
|
||||
"ports": [p.to_dict() for p in self.ports],
|
||||
"properties": self.properties.to_dict(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "NodeModel":
|
||||
return cls(
|
||||
data["id"],
|
||||
data["template"],
|
||||
svg_name=data.get("svg", ""),
|
||||
x=data.get("x", 0.0),
|
||||
y=data.get("y", 0.0),
|
||||
width=data.get("width", 80.0),
|
||||
height=data.get("height", 80.0),
|
||||
rotation=data.get("rotation", 0.0),
|
||||
ports=[Port.from_dict(p) for p in data.get("ports", [])],
|
||||
properties=PropertyBag.from_dict(data.get("properties", {})),
|
||||
)
|
||||
|
||||
def clone(self, new_id: str) -> "NodeModel":
|
||||
d = self.to_dict()
|
||||
d["id"] = new_id
|
||||
return NodeModel.from_dict(d)
|
||||
204
pipeline_editor/model/node_library.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""Built-in node templates: SVG symbol + port configuration + default props.
|
||||
|
||||
A :class:`NodeTemplate` is a reusable definition dragged from the node panel to
|
||||
create :class:`NodeModel` instances on the canvas.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Iterable, Optional
|
||||
|
||||
from .node import NodeModel
|
||||
from .port import Port, PortDirection, PortSide
|
||||
from .properties import Property, PropertyBag, PropertyGroup, PropertyType
|
||||
|
||||
|
||||
def _presentation(title: str, color: str = "#37474f") -> PropertyGroup:
|
||||
return PropertyGroup("Presentation", [
|
||||
Property("title", "Title", PropertyType.STRING, title),
|
||||
Property("color", "Color", PropertyType.COLOR, color),
|
||||
Property("notes", "Notes", PropertyType.STRING, ""),
|
||||
])
|
||||
|
||||
|
||||
class NodeTemplate:
|
||||
"""Definition of a draggable node type."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
label: str,
|
||||
group: str,
|
||||
svg_name: str,
|
||||
ports: Iterable[Port],
|
||||
*,
|
||||
width: float = 80.0,
|
||||
height: float = 80.0,
|
||||
default_color: str = "#37474f",
|
||||
physics: Optional[Callable[[], PropertyGroup]] = None,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.group = group
|
||||
self.svg_name = svg_name
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.default_color = default_color
|
||||
self._ports = list(ports)
|
||||
self._physics = physics
|
||||
|
||||
def make_properties(self) -> PropertyBag:
|
||||
bag = PropertyBag([_presentation(self.label, self.default_color)])
|
||||
if self._physics is not None:
|
||||
bag.add_group(self._physics())
|
||||
return bag
|
||||
|
||||
def instantiate(self, node_id: str, x: float, y: float) -> NodeModel:
|
||||
return NodeModel(
|
||||
node_id,
|
||||
self.key,
|
||||
svg_name=self.svg_name,
|
||||
x=x,
|
||||
y=y,
|
||||
width=self.width,
|
||||
height=self.height,
|
||||
ports=[p.clone() for p in self._ports],
|
||||
properties=self.make_properties(),
|
||||
)
|
||||
|
||||
|
||||
class NodeLibrary:
|
||||
"""Ordered collection of templates, grouped for the node panel."""
|
||||
|
||||
def __init__(self, templates: Optional[Iterable[NodeTemplate]] = None) -> None:
|
||||
self._templates: dict[str, NodeTemplate] = {}
|
||||
for t in templates or []:
|
||||
self.add(t)
|
||||
|
||||
def add(self, template: NodeTemplate) -> NodeTemplate:
|
||||
self._templates[template.key] = template
|
||||
return template
|
||||
|
||||
def get(self, key: str) -> Optional[NodeTemplate]:
|
||||
return self._templates.get(key)
|
||||
|
||||
def groups(self) -> dict[str, list[NodeTemplate]]:
|
||||
out: dict[str, list[NodeTemplate]] = {}
|
||||
for t in self._templates.values():
|
||||
out.setdefault(t.group, []).append(t)
|
||||
return out
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._templates.values())
|
||||
|
||||
|
||||
# -- physics group builders ----------------------------------------------
|
||||
def _pump_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("head", "Rated Head", PropertyType.FLOAT, 30.0, unit="m", minimum=0),
|
||||
Property("power", "Power", PropertyType.FLOAT, 5.5, unit="kW", minimum=0),
|
||||
Property("status", "Status", PropertyType.ENUM, "on", options=["on", "off"]),
|
||||
])
|
||||
|
||||
|
||||
def _tank_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("volume", "Volume", PropertyType.FLOAT, 100.0, unit="m3", minimum=0),
|
||||
Property("level", "Level", PropertyType.FLOAT, 50.0, unit="%", minimum=0, maximum=100),
|
||||
Property("elevation", "Elevation", PropertyType.FLOAT, 0.0, unit="m"),
|
||||
])
|
||||
|
||||
|
||||
def _valve_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("diameter", "Diameter", PropertyType.CATALOGUE_ITEM, "DN100",
|
||||
catalogue="pipe_classes"),
|
||||
Property("state", "State", PropertyType.ENUM, "open",
|
||||
options=["open", "closed", "throttled"]),
|
||||
Property("opening", "Opening", PropertyType.FLOAT, 100.0, unit="%",
|
||||
minimum=0, maximum=100),
|
||||
])
|
||||
|
||||
|
||||
def _source_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("supply", "Supply Rate", PropertyType.FLOAT, 120.0, unit="m3/h", minimum=0),
|
||||
Property("pressure", "Pressure", PropertyType.FLOAT, 4.0, unit="bar", minimum=0),
|
||||
])
|
||||
|
||||
|
||||
def _sink_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("demand", "Demand", PropertyType.FLOAT, 40.0, unit="m3/h", minimum=0),
|
||||
Property("flow_type", "Flow Type", PropertyType.ENUM, "steady",
|
||||
options=["steady", "peak", "intermittent"]),
|
||||
])
|
||||
|
||||
|
||||
def _junction_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("elevation", "Elevation", PropertyType.FLOAT, 0.0, unit="m"),
|
||||
])
|
||||
|
||||
|
||||
def _transformer_physics() -> PropertyGroup:
|
||||
return PropertyGroup("Physics", [
|
||||
Property("rating", "Rating", PropertyType.FLOAT, 250.0, unit="kVA", minimum=0),
|
||||
Property("primary_v", "Primary", PropertyType.FLOAT, 11.0, unit="kV", minimum=0),
|
||||
Property("secondary_v", "Secondary", PropertyType.FLOAT, 0.4, unit="kV", minimum=0),
|
||||
])
|
||||
|
||||
|
||||
def default_library() -> NodeLibrary:
|
||||
"""A starter library covering water and power network symbols."""
|
||||
W = "water"
|
||||
P = "power"
|
||||
lib = NodeLibrary()
|
||||
|
||||
lib.add(NodeTemplate(
|
||||
"source", "Water Source", "Sources", "source.svg",
|
||||
[Port("out", W, PortSide.RIGHT, 0.5, name="Outlet", direction=PortDirection.OUTPUT)],
|
||||
default_color="#1565c0", physics=_source_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"tank", "Storage Tank", "Storage", "tank.svg",
|
||||
[Port("in", W, PortSide.TOP, 0.35, name="Inlet", direction=PortDirection.INPUT),
|
||||
Port("out", W, PortSide.BOTTOM, 0.65, name="Outlet", direction=PortDirection.OUTPUT)],
|
||||
default_color="#0277bd", physics=_tank_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"pump", "Pump", "Equipment", "pump.svg",
|
||||
[Port("in", W, PortSide.LEFT, 0.5, name="Suction", direction=PortDirection.INPUT),
|
||||
Port("out", W, PortSide.RIGHT, 0.5, name="Discharge", direction=PortDirection.OUTPUT)],
|
||||
default_color="#00838f", physics=_pump_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"valve", "Valve", "Equipment", "valve.svg",
|
||||
[Port("in", W, PortSide.LEFT, 0.5, name="In", direction=PortDirection.INPUT),
|
||||
Port("out", W, PortSide.RIGHT, 0.5, name="Out", direction=PortDirection.OUTPUT)],
|
||||
width=80, height=50, default_color="#455a64", physics=_valve_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"junction", "Junction", "Junctions", "junction.svg",
|
||||
[Port("n", W, PortSide.TOP, 0.5, name="N"),
|
||||
Port("e", W, PortSide.RIGHT, 0.5, name="E"),
|
||||
Port("s", W, PortSide.BOTTOM, 0.5, name="S"),
|
||||
Port("w", W, PortSide.LEFT, 0.5, name="W")],
|
||||
width=50, height=50, default_color="#546e7a", physics=_junction_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"consumer", "Consumer", "Sinks", "consumer.svg",
|
||||
[Port("in", W, PortSide.LEFT, 0.5, name="Inlet", direction=PortDirection.INPUT)],
|
||||
default_color="#5d4037", physics=_sink_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"transformer", "Transformer", "Power", "transformer.svg",
|
||||
[Port("hv", P, PortSide.TOP, 0.5, name="HV", direction=PortDirection.INPUT),
|
||||
Port("lv", P, PortSide.BOTTOM, 0.5, name="LV", direction=PortDirection.OUTPUT)],
|
||||
default_color="#2e7d32", physics=_transformer_physics,
|
||||
))
|
||||
lib.add(NodeTemplate(
|
||||
"power_source", "Power Source", "Power", "power_source.svg",
|
||||
[Port("out", P, PortSide.RIGHT, 0.5, name="Feed", direction=PortDirection.OUTPUT)],
|
||||
default_color="#388e3c", physics=_source_physics,
|
||||
))
|
||||
return lib
|
||||
98
pipeline_editor/model/port.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Ports: typed connection anchors positioned on a node's boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PortSide(str, Enum):
|
||||
LEFT = "left"
|
||||
RIGHT = "right"
|
||||
TOP = "top"
|
||||
BOTTOM = "bottom"
|
||||
|
||||
|
||||
class PortDirection(str, Enum):
|
||||
"""Whether a port emits, receives, or does both."""
|
||||
|
||||
INPUT = "input"
|
||||
OUTPUT = "output"
|
||||
BIDIRECTIONAL = "bidirectional"
|
||||
|
||||
|
||||
class Port:
|
||||
"""A connection anchor on a node.
|
||||
|
||||
Position is stored as a normalized offset (0..1) along the given side, so it
|
||||
scales with the node's bounding box.
|
||||
|
||||
Attributes:
|
||||
port_id: unique within the owning node.
|
||||
name: display name.
|
||||
relation: relation key (see :mod:`relations`).
|
||||
side: which edge of the node it sits on.
|
||||
offset: 0..1 position along that side.
|
||||
direction: input / output / bidirectional.
|
||||
"""
|
||||
|
||||
__slots__ = ("port_id", "name", "relation", "side", "offset", "direction")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port_id: str,
|
||||
relation: str,
|
||||
side: PortSide,
|
||||
offset: float = 0.5,
|
||||
*,
|
||||
name: str = "",
|
||||
direction: PortDirection = PortDirection.BIDIRECTIONAL,
|
||||
) -> None:
|
||||
self.port_id = port_id
|
||||
self.name = name or port_id
|
||||
self.relation = relation
|
||||
self.side = PortSide(side)
|
||||
self.offset = min(1.0, max(0.0, float(offset)))
|
||||
self.direction = PortDirection(direction)
|
||||
|
||||
def local_point(self, width: float, height: float) -> tuple[float, float]:
|
||||
"""Return the port position in node-local coordinates (origin at 0,0)."""
|
||||
if self.side is PortSide.LEFT:
|
||||
return 0.0, height * self.offset
|
||||
if self.side is PortSide.RIGHT:
|
||||
return width, height * self.offset
|
||||
if self.side is PortSide.TOP:
|
||||
return width * self.offset, 0.0
|
||||
return width * self.offset, height # BOTTOM
|
||||
|
||||
def normal(self) -> tuple[float, float]:
|
||||
"""Outward unit normal for the port's side (used to stub-out edges)."""
|
||||
return {
|
||||
PortSide.LEFT: (-1.0, 0.0),
|
||||
PortSide.RIGHT: (1.0, 0.0),
|
||||
PortSide.TOP: (0.0, -1.0),
|
||||
PortSide.BOTTOM: (0.0, 1.0),
|
||||
}[self.side]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.port_id,
|
||||
"name": self.name,
|
||||
"relation": self.relation,
|
||||
"side": self.side.value,
|
||||
"offset": self.offset,
|
||||
"direction": self.direction.value,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Port":
|
||||
return cls(
|
||||
data["id"],
|
||||
data["relation"],
|
||||
PortSide(data["side"]),
|
||||
data.get("offset", 0.5),
|
||||
name=data.get("name", ""),
|
||||
direction=PortDirection(data.get("direction", "bidirectional")),
|
||||
)
|
||||
|
||||
def clone(self) -> "Port":
|
||||
return Port.from_dict(self.to_dict())
|
||||
228
pipeline_editor/model/properties.py
Normal file
@ -0,0 +1,228 @@
|
||||
"""Typed, groupable properties for nodes and edges.
|
||||
|
||||
The model layer is intentionally UI-agnostic (no Qt imports) so it can be unit
|
||||
tested without a running QApplication. Colors are stored as ``#rrggbb`` strings
|
||||
and converted to ``QColor`` only in the view layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Iterable, Iterator, Optional
|
||||
|
||||
|
||||
class PropertyType(str, Enum):
|
||||
"""Supported property value types."""
|
||||
|
||||
STRING = "string"
|
||||
INT = "int"
|
||||
FLOAT = "float"
|
||||
BOOL = "bool"
|
||||
ENUM = "enum"
|
||||
COLOR = "color"
|
||||
CATALOGUE_ITEM = "catalogue_item" # single reference into a catalogue
|
||||
CATALOGUE_ITEMS = "catalogue_items" # multiple references into a catalogue
|
||||
VALUE_LIST = "value_list" # free list of scalar values
|
||||
|
||||
|
||||
# Coercion helpers keep stored values well-typed after edits/deserialization.
|
||||
def _coerce(ptype: "PropertyType", value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
if ptype is PropertyType.INT:
|
||||
return int(value)
|
||||
if ptype is PropertyType.FLOAT:
|
||||
return float(value)
|
||||
if ptype is PropertyType.BOOL:
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(value)
|
||||
if ptype in (PropertyType.CATALOGUE_ITEMS, PropertyType.VALUE_LIST):
|
||||
return list(value)
|
||||
return value
|
||||
|
||||
|
||||
class Property:
|
||||
"""A single named, typed property value.
|
||||
|
||||
Attributes:
|
||||
key: stable machine identifier (unique within its group).
|
||||
label: human-readable name.
|
||||
ptype: :class:`PropertyType`.
|
||||
value: current value (type depends on ``ptype``).
|
||||
options: allowed values for ENUM types.
|
||||
catalogue: catalogue name for CATALOGUE_ITEM(S) types.
|
||||
unit: optional unit suffix shown in the editor (e.g. "mm", "m3/h").
|
||||
editable: if False the editor renders read-only.
|
||||
minimum/maximum: optional numeric bounds.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"key", "label", "ptype", "value", "options", "catalogue",
|
||||
"unit", "editable", "minimum", "maximum", "on_change",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
label: str,
|
||||
ptype: PropertyType,
|
||||
value: Any = None,
|
||||
*,
|
||||
options: Optional[Iterable[str]] = None,
|
||||
catalogue: Optional[str] = None,
|
||||
unit: str = "",
|
||||
editable: bool = True,
|
||||
minimum: Optional[float] = None,
|
||||
maximum: Optional[float] = None,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.ptype = PropertyType(ptype)
|
||||
self.options = list(options) if options else []
|
||||
self.catalogue = catalogue
|
||||
self.unit = unit
|
||||
self.editable = editable
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.on_change: Optional[Callable[["Property"], None]] = None
|
||||
self.value = _coerce(self.ptype, value)
|
||||
|
||||
def set_value(self, value: Any) -> bool:
|
||||
"""Set a coerced, bounds-clamped value. Returns True if it changed."""
|
||||
new = _coerce(self.ptype, value)
|
||||
if self.ptype in (PropertyType.INT, PropertyType.FLOAT) and new is not None:
|
||||
if self.minimum is not None:
|
||||
new = max(new, self.minimum)
|
||||
if self.maximum is not None:
|
||||
new = min(new, self.maximum)
|
||||
if new == self.value:
|
||||
return False
|
||||
self.value = new
|
||||
if self.on_change:
|
||||
self.on_change(self)
|
||||
return True
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"type": self.ptype.value,
|
||||
"value": self.value,
|
||||
"options": self.options,
|
||||
"catalogue": self.catalogue,
|
||||
"unit": self.unit,
|
||||
"editable": self.editable,
|
||||
"minimum": self.minimum,
|
||||
"maximum": self.maximum,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "Property":
|
||||
return cls(
|
||||
data["key"],
|
||||
data.get("label", data["key"]),
|
||||
PropertyType(data["type"]),
|
||||
data.get("value"),
|
||||
options=data.get("options"),
|
||||
catalogue=data.get("catalogue"),
|
||||
unit=data.get("unit", ""),
|
||||
editable=data.get("editable", True),
|
||||
minimum=data.get("minimum"),
|
||||
maximum=data.get("maximum"),
|
||||
)
|
||||
|
||||
def clone(self) -> "Property":
|
||||
return Property.from_dict(self.to_dict())
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"Property({self.key!r}={self.value!r}:{self.ptype.value})"
|
||||
|
||||
|
||||
class PropertyGroup:
|
||||
"""An ordered, named collection of properties (e.g. 'Geometry', 'Physics')."""
|
||||
|
||||
def __init__(self, name: str, properties: Optional[Iterable[Property]] = None) -> None:
|
||||
self.name = name
|
||||
self._props: list[Property] = list(properties or [])
|
||||
|
||||
def add(self, prop: Property) -> Property:
|
||||
self._props.append(prop)
|
||||
return prop
|
||||
|
||||
def get(self, key: str) -> Optional[Property]:
|
||||
for p in self._props:
|
||||
if p.key == key:
|
||||
return p
|
||||
return None
|
||||
|
||||
def value(self, key: str, default: Any = None) -> Any:
|
||||
p = self.get(key)
|
||||
return p.value if p is not None else default
|
||||
|
||||
def __iter__(self) -> Iterator[Property]:
|
||||
return iter(self._props)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._props)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"name": self.name, "properties": [p.to_dict() for p in self._props]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PropertyGroup":
|
||||
return cls(data["name"], [Property.from_dict(p) for p in data.get("properties", [])])
|
||||
|
||||
def clone(self) -> "PropertyGroup":
|
||||
return PropertyGroup(self.name, [p.clone() for p in self._props])
|
||||
|
||||
|
||||
class PropertyBag:
|
||||
"""An ordered set of property groups with convenient key lookup.
|
||||
|
||||
Keys are unique across the whole bag, so ``bag.value("title")`` works
|
||||
regardless of which group holds it.
|
||||
"""
|
||||
|
||||
def __init__(self, groups: Optional[Iterable[PropertyGroup]] = None) -> None:
|
||||
self._groups: list[PropertyGroup] = list(groups or [])
|
||||
|
||||
def add_group(self, group: PropertyGroup) -> PropertyGroup:
|
||||
self._groups.append(group)
|
||||
return group
|
||||
|
||||
def group(self, name: str) -> Optional[PropertyGroup]:
|
||||
for g in self._groups:
|
||||
if g.name == name:
|
||||
return g
|
||||
return None
|
||||
|
||||
def find(self, key: str) -> Optional[Property]:
|
||||
for g in self._groups:
|
||||
p = g.get(key)
|
||||
if p is not None:
|
||||
return p
|
||||
return None
|
||||
|
||||
def value(self, key: str, default: Any = None) -> Any:
|
||||
p = self.find(key)
|
||||
return p.value if p is not None else default
|
||||
|
||||
def set_value(self, key: str, value: Any) -> bool:
|
||||
p = self.find(key)
|
||||
return p.set_value(value) if p is not None else False
|
||||
|
||||
def __iter__(self) -> Iterator[PropertyGroup]:
|
||||
return iter(self._groups)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._groups)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"groups": [g.to_dict() for g in self._groups]}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PropertyBag":
|
||||
return cls([PropertyGroup.from_dict(g) for g in data.get("groups", [])])
|
||||
|
||||
def clone(self) -> "PropertyBag":
|
||||
return PropertyBag([g.clone() for g in self._groups])
|
||||
135
pipeline_editor/model/relations.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Port relations (media/domain a port carries) and their color schemes.
|
||||
|
||||
A relation like ``water`` or ``power`` defines both a palette and the set of
|
||||
relations it may connect to. Ports may only be wired together when their
|
||||
relations are mutually compatible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
|
||||
class PortRelation:
|
||||
"""A connectable domain, e.g. water / power / gas.
|
||||
|
||||
Attributes:
|
||||
key: stable identifier.
|
||||
label: display name.
|
||||
color: base ``#rrggbb`` color used for ports and edges of this relation.
|
||||
compatible: set of relation keys this one may connect to. A relation is
|
||||
always compatible with itself; ``compatible`` lists *additional* keys.
|
||||
"""
|
||||
|
||||
__slots__ = ("key", "label", "color", "compatible")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
label: str,
|
||||
color: str,
|
||||
compatible: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.color = color
|
||||
self.compatible = set(compatible or [])
|
||||
|
||||
def can_connect(self, other: "PortRelation") -> bool:
|
||||
if other.key == self.key:
|
||||
return True
|
||||
return other.key in self.compatible or self.key in other.compatible
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"key": self.key,
|
||||
"label": self.label,
|
||||
"color": self.color,
|
||||
"compatible": sorted(self.compatible),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "PortRelation":
|
||||
return cls(data["key"], data.get("label", data["key"]),
|
||||
data.get("color", "#888888"), data.get("compatible"))
|
||||
|
||||
|
||||
class RelationRegistry:
|
||||
"""Registry of relations plus named color schemes.
|
||||
|
||||
A *color scheme* remaps relation base colors, letting the whole diagram be
|
||||
re-themed (e.g. light vs. dark, or a high-contrast palette) without touching
|
||||
per-item data.
|
||||
"""
|
||||
|
||||
def __init__(self, relations: Optional[Iterable[PortRelation]] = None) -> None:
|
||||
self._relations: dict[str, PortRelation] = {}
|
||||
for r in relations or []:
|
||||
self.register(r)
|
||||
# scheme name -> {relation_key: color}
|
||||
self._schemes: dict[str, dict[str, str]] = {}
|
||||
self.active_scheme = "default"
|
||||
|
||||
def register(self, relation: PortRelation) -> PortRelation:
|
||||
self._relations[relation.key] = relation
|
||||
return relation
|
||||
|
||||
def get(self, key: str) -> Optional[PortRelation]:
|
||||
return self._relations.get(key)
|
||||
|
||||
def __iter__(self) -> Iterator[PortRelation]:
|
||||
return iter(self._relations.values())
|
||||
|
||||
def can_connect(self, key_a: str, key_b: str) -> bool:
|
||||
a, b = self._relations.get(key_a), self._relations.get(key_b)
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return a.can_connect(b)
|
||||
|
||||
# -- color schemes ----------------------------------------------------
|
||||
def add_scheme(self, name: str, colors: dict[str, str]) -> None:
|
||||
self._schemes[name] = dict(colors)
|
||||
|
||||
def scheme_names(self) -> list[str]:
|
||||
return ["default", *[n for n in self._schemes if n != "default"]]
|
||||
|
||||
def set_active_scheme(self, name: str) -> None:
|
||||
self.active_scheme = name
|
||||
|
||||
def color(self, relation_key: str) -> str:
|
||||
"""Resolve the color for a relation under the active scheme."""
|
||||
scheme = self._schemes.get(self.active_scheme, {})
|
||||
if relation_key in scheme:
|
||||
return scheme[relation_key]
|
||||
rel = self._relations.get(relation_key)
|
||||
return rel.color if rel else "#888888"
|
||||
|
||||
|
||||
def default_registry() -> RelationRegistry:
|
||||
"""Starter relations covering common utility networks."""
|
||||
reg = RelationRegistry([
|
||||
PortRelation("water", "Water", "#1e88e5"),
|
||||
PortRelation("hot_water", "Hot Water", "#e53935", compatible=["water"]),
|
||||
PortRelation("gas", "Gas", "#fdd835"),
|
||||
PortRelation("power", "Power", "#43a047"),
|
||||
PortRelation("signal", "Signal", "#8e24aa"),
|
||||
PortRelation("sewage", "Sewage", "#6d4c41"),
|
||||
])
|
||||
# A high-contrast alternative scheme.
|
||||
reg.add_scheme("high_contrast", {
|
||||
"water": "#0000ff",
|
||||
"hot_water": "#ff0000",
|
||||
"gas": "#ffcc00",
|
||||
"power": "#00cc00",
|
||||
"signal": "#cc00cc",
|
||||
"sewage": "#663300",
|
||||
})
|
||||
# A muted / dark-friendly scheme.
|
||||
reg.add_scheme("muted", {
|
||||
"water": "#6fa8dc",
|
||||
"hot_water": "#e06666",
|
||||
"gas": "#ffd966",
|
||||
"power": "#93c47d",
|
||||
"signal": "#b4a7d6",
|
||||
"sewage": "#a67c52",
|
||||
})
|
||||
return reg
|
||||
5
pipeline_editor/resources/nodes/consumer.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M22 45 L50 20 L78 45 L78 82 L22 82 Z" fill="#efebe9" stroke="#5d4037" stroke-width="4"/>
|
||||
<rect x="42" y="60" width="16" height="22" fill="#5d4037"/>
|
||||
<line x1="6" y1="55" x2="22" y2="55" stroke="#5d4037" stroke-width="5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
5
pipeline_editor/resources/nodes/junction.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<line x1="50" y1="8" x2="50" y2="92" stroke="#546e7a" stroke-width="6"/>
|
||||
<line x1="8" y1="50" x2="92" y2="50" stroke="#546e7a" stroke-width="6"/>
|
||||
<circle cx="50" cy="50" r="12" fill="#546e7a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 270 B |
5
pipeline_editor/resources/nodes/power_source.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="46" cy="50" r="38" fill="#e8f5e9" stroke="#388e3c" stroke-width="4"/>
|
||||
<path d="M48 18 L30 54 L46 54 L40 82 L64 44 L48 44 Z" fill="#388e3c"/>
|
||||
<rect x="84" y="45" width="14" height="10" fill="#388e3c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
8
pipeline_editor/resources/nodes/pump.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="34" fill="#e0f7fa" stroke="#00838f" stroke-width="4"/>
|
||||
<path d="M50 50 L50 20 A30 30 0 0 1 76 65 Z" fill="#00838f" opacity="0.85"/>
|
||||
<path d="M50 50 L24 65 A30 30 0 0 1 50 20 Z" fill="#00838f" opacity="0.55"/>
|
||||
<circle cx="50" cy="50" r="6" fill="#004d55"/>
|
||||
<line x1="6" y1="50" x2="16" y2="50" stroke="#00838f" stroke-width="5"/>
|
||||
<line x1="84" y1="50" x2="94" y2="50" stroke="#00838f" stroke-width="5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 512 B |
6
pipeline_editor/resources/nodes/source.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="6" y="20" width="70" height="60" rx="6" fill="#e3f2fd" stroke="#1565c0" stroke-width="4"/>
|
||||
<path d="M6 55 q17 -12 35 0 t35 0" fill="none" stroke="#1565c0" stroke-width="4"/>
|
||||
<path d="M6 68 q17 -12 35 0 t35 0" fill="none" stroke="#1565c0" stroke-width="3"/>
|
||||
<rect x="76" y="45" width="18" height="10" fill="#1565c0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
7
pipeline_editor/resources/nodes/tank.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="20" y="10" width="60" height="80" rx="10" fill="#e1f5fe" stroke="#0277bd" stroke-width="4"/>
|
||||
<rect x="20" y="55" width="60" height="35" rx="6" fill="#4fc3f7" stroke="#0277bd" stroke-width="0"/>
|
||||
<rect x="20" y="10" width="60" height="80" rx="10" fill="none" stroke="#0277bd" stroke-width="4"/>
|
||||
<line x1="35" y1="6" x2="35" y2="10" stroke="#0277bd" stroke-width="5"/>
|
||||
<line x1="65" y1="90" x2="65" y2="96" stroke="#0277bd" stroke-width="5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 529 B |
6
pipeline_editor/resources/nodes/transformer.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="34" r="22" fill="none" stroke="#2e7d32" stroke-width="5"/>
|
||||
<circle cx="50" cy="66" r="22" fill="none" stroke="#2e7d32" stroke-width="5"/>
|
||||
<line x1="50" y1="4" x2="50" y2="12" stroke="#2e7d32" stroke-width="5"/>
|
||||
<line x1="50" y1="88" x2="50" y2="96" stroke="#2e7d32" stroke-width="5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 383 B |
8
pipeline_editor/resources/nodes/valve.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path d="M18 25 L50 50 L18 75 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="4"/>
|
||||
<path d="M82 25 L50 50 L82 75 Z" fill="#cfd8dc" stroke="#455a64" stroke-width="4"/>
|
||||
<line x1="50" y1="50" x2="50" y2="22" stroke="#455a64" stroke-width="4"/>
|
||||
<rect x="38" y="12" width="24" height="10" rx="3" fill="#455a64"/>
|
||||
<line x1="4" y1="50" x2="18" y2="50" stroke="#455a64" stroke-width="5"/>
|
||||
<line x1="82" y1="50" x2="96" y2="50" stroke="#455a64" stroke-width="5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 538 B |
80
tests/test_document.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""DiagramDocument tests: add/remove, connect rules, signals, serialization."""
|
||||
import pytest
|
||||
|
||||
from pipeline_editor.model.document import DiagramDocument
|
||||
from pipeline_editor.model.edge import EdgeModel
|
||||
from pipeline_editor.model.node_library import default_library
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def doc(qapp):
|
||||
return DiagramDocument()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lib():
|
||||
return default_library()
|
||||
|
||||
|
||||
def _add(doc, lib, key, x, y):
|
||||
node = lib.get(key).instantiate(doc.next_id("n"), x, y)
|
||||
return doc.add_node(node)
|
||||
|
||||
|
||||
def test_add_and_signals(doc, lib):
|
||||
events = []
|
||||
doc.node_added.connect(lambda nid: events.append(("node", nid)))
|
||||
doc.edge_added.connect(lambda eid: events.append(("edge", eid)))
|
||||
a = _add(doc, lib, "source", 0, 0)
|
||||
b = _add(doc, lib, "pump", 200, 0)
|
||||
edge = EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in")
|
||||
doc.add_edge(edge)
|
||||
assert ("node", a.node_id) in events
|
||||
assert ("edge", edge.edge_id) in events
|
||||
assert doc.dirty is True
|
||||
|
||||
|
||||
def test_connect_rules(doc, lib):
|
||||
a = _add(doc, lib, "source", 0, 0) # water out
|
||||
b = _add(doc, lib, "pump", 200, 0) # water in
|
||||
t = _add(doc, lib, "transformer", 0, 200) # power ports
|
||||
assert doc.can_connect(a.node_id, "out", b.node_id, "in") is True
|
||||
assert doc.can_connect(a.node_id, "out", t.node_id, "hv") is False # water vs power
|
||||
assert doc.can_connect(a.node_id, "out", a.node_id, "out") is False # same port
|
||||
|
||||
|
||||
def test_remove_node_cascades_edges(doc, lib):
|
||||
a = _add(doc, lib, "source", 0, 0)
|
||||
b = _add(doc, lib, "pump", 200, 0)
|
||||
e = doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
|
||||
removed = doc.remove_node(a.node_id)
|
||||
assert e.edge_id in removed
|
||||
assert doc.edge(e.edge_id) is None
|
||||
assert doc.node(a.node_id) is None
|
||||
|
||||
|
||||
def test_edge_inherits_relation(doc, lib):
|
||||
a = _add(doc, lib, "source", 0, 0)
|
||||
b = _add(doc, lib, "pump", 200, 0)
|
||||
e = doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
|
||||
assert e.relation == "water"
|
||||
|
||||
|
||||
def test_serialization_roundtrip(doc, lib):
|
||||
a = _add(doc, lib, "source", 10, 20)
|
||||
b = _add(doc, lib, "consumer", 300, 20)
|
||||
a.properties.set_value("supply", 200)
|
||||
doc.add_edge(EdgeModel(doc.next_id("e"), a.node_id, "out", b.node_id, "in"))
|
||||
text = doc.to_json()
|
||||
|
||||
doc2 = DiagramDocument.from_json(text)
|
||||
assert list(n.node_id for n in doc2.nodes()) == [a.node_id, b.node_id]
|
||||
ra = doc2.node(a.node_id)
|
||||
assert ra.x == 10 and ra.properties.value("supply") == 200
|
||||
assert sum(1 for _ in doc2.edges()) == 1
|
||||
|
||||
|
||||
def test_clear(doc, lib):
|
||||
_add(doc, lib, "source", 0, 0)
|
||||
doc.clear()
|
||||
assert len(doc) == 0
|
||||
92
tests/test_model.py
Normal file
@ -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()
|
||||