Ilya 62780c66be feat(model): domain model, node library, and SVG symbols
Add UI-agnostic model layer: typed grouped properties (string/int/float/
bool/enum/color/catalogue item(s)/value list), catalogue registry, port
relations with color schemes and compatibility rules, ports, node/edge
models, and DiagramDocument with change signals and JSON round-trip.
Include a starter node library (source/tank/pump/valve/junction/consumer/
transformer) with matching SVG symbols, plus model+document tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:13:18 +02:00

229 lines
7.3 KiB
Python

"""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])