mnemo-ocr-core / src /element_library.py
MABobrov's picture
Deploy updated core backend pipeline
7fb79e4
"""Element library catalog for the Mnemo Studio frontend.
Builds a JSON-serializable catalog from Widget.json and Statics.xml
without sending full template XML to the client.
"""
from __future__ import annotations
import json
import re
import xml.etree.ElementTree as ET
from functools import lru_cache
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
_DEFAULT_WIDGET_JSON = _PROJECT_ROOT / "mnemo-studio-tool" / "web" / "Widget.json"
_DEFAULT_STATICS_XML = _PROJECT_ROOT / "mnemo-studio-tool" / "web" / "Statics.xml"
_WIDGET_CATEGORY_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("equipment", re.compile(r"pump|fan|compressor|motor|conveyor|mixer|crusher|kiln|mill|elevator|separator|scrubber|filter|heater|boiler|dryer|screener|thickener|centrifuge|autoclave|batcher|cyclone|deaerator|magnet|turbogenerator|supercharger", re.I)),
("valve", re.compile(r"valve|gate", re.I)),
("indicator", re.compile(r"status|indicator|tachometer|fire|button|dict|index|DateTime", re.I)),
("value", re.compile(r"analog_value|string_|uom_|value_table|level", re.I)),
("table", re.compile(r"table|equipment_table", re.I)),
("static", re.compile(r"^static_", re.I)),
("arrow", re.compile(r"arrow", re.I)),
]
def _categorize_widget(name: str) -> str:
for category, pattern in _WIDGET_CATEGORY_PATTERNS:
if pattern.search(name):
return category
return "other"
def _extract_widget_dimensions(xml_text: str) -> tuple[float, float]:
"""Extract approximate dimensions from first geometry in widget XML."""
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
return 40.0, 40.0
for geom in root.iter("mxGeometry"):
w = float(geom.get("width") or 0)
h = float(geom.get("height") or 0)
if w > 0 and h > 0:
return w, h
return 40.0, 40.0
def _convert_shape_to_svg_path(shape_el: ET.Element) -> str:
"""Convert Statics.xml <shape> foreground commands to SVG path string."""
fg = shape_el.find("foreground")
if fg is None:
return ""
parts: list[str] = []
for child in fg:
tag = child.tag.lower()
if tag == "path":
for cmd in child:
cmd_tag = cmd.tag.lower()
if cmd_tag == "move":
parts.append(f"M {cmd.get('x', '0')} {cmd.get('y', '0')}")
elif cmd_tag == "line":
parts.append(f"L {cmd.get('x', '0')} {cmd.get('y', '0')}")
elif cmd_tag == "curve":
x1 = cmd.get("x1", "0")
y1 = cmd.get("y1", "0")
x2 = cmd.get("x2", "0")
y2 = cmd.get("y2", "0")
x3 = cmd.get("x3", "0")
y3 = cmd.get("y3", "0")
parts.append(f"C {x1} {y1} {x2} {y2} {x3} {y3}")
elif cmd_tag == "close":
parts.append("Z")
elif tag == "rect":
x = float(child.get("x", "0"))
y = float(child.get("y", "0"))
w = float(child.get("w", "0"))
h = float(child.get("h", "0"))
parts.append(f"M {x} {y} L {x+w} {y} L {x+w} {y+h} L {x} {y+h} Z")
elif tag == "roundrect":
x = float(child.get("x", "0"))
y = float(child.get("y", "0"))
w = float(child.get("w", "0"))
h = float(child.get("h", "0"))
parts.append(f"M {x} {y} L {x+w} {y} L {x+w} {y+h} L {x} {y+h} Z")
elif tag == "ellipse":
cx = float(child.get("x", "0")) + float(child.get("w", "0")) / 2
cy = float(child.get("y", "0")) + float(child.get("h", "0")) / 2
rx = float(child.get("w", "0")) / 2
ry = float(child.get("h", "0")) / 2
parts.append(
f"M {cx-rx} {cy} "
f"A {rx} {ry} 0 1 0 {cx+rx} {cy} "
f"A {rx} {ry} 0 1 0 {cx-rx} {cy} Z"
)
return " ".join(parts)
def _load_statics_catalog(statics_path: Path) -> list[dict[str, Any]]:
if not statics_path.exists():
return []
try:
tree = ET.parse(statics_path)
except ET.ParseError:
return []
items: list[dict[str, Any]] = []
for shape in tree.iter("shape"):
name = shape.get("name") or ""
if not name:
continue
w = float(shape.get("w") or 0)
h = float(shape.get("h") or 0)
svg_path = _convert_shape_to_svg_path(shape)
display_name = (shape.get("displayName") or name).replace("_", " ")
items.append({
"name": name,
"displayName": display_name,
"width": round(w, 1),
"height": round(h, 1),
"aspect": shape.get("aspect") or "variable",
"svgPath": svg_path,
})
return sorted(items, key=lambda item: item["name"].lower())
def _load_widgets_catalog(widget_path: Path) -> list[dict[str, Any]]:
if not widget_path.exists():
return []
try:
with open(widget_path, "r", encoding="utf-8") as f:
raw = json.load(f)
except (json.JSONDecodeError, OSError):
return []
items: list[dict[str, Any]] = []
for entry in (raw if isinstance(raw, list) else []):
name = str(entry.get("name") or "").strip()
if not name:
continue
xml_text = str(entry.get("xml") or "")
w, h = _extract_widget_dimensions(xml_text) if xml_text else (40.0, 40.0)
category = _categorize_widget(name)
display_name = name.replace("_", " ").replace("dynamic ", "")
items.append({
"name": name,
"displayName": display_name,
"category": category,
"width": round(w, 1),
"height": round(h, 1),
})
return sorted(items, key=lambda item: item["name"].lower())
_BASIC_ELEMENTS: list[dict[str, str]] = [
{"name": "rectangle", "label": "Прямоугольник", "icon": "rect"},
{"name": "text", "label": "Текст", "icon": "text"},
{"name": "line", "label": "Линия", "icon": "line"},
{"name": "arrow", "label": "Стрелка", "icon": "arrow"},
{"name": "ellipse", "label": "Эллипс", "icon": "ellipse"},
{"name": "table", "label": "Таблица", "icon": "table"},
]
_FALLBACK_STATICS: list[dict[str, Any]] = [
{"name": "tank", "displayName": "Резервуар", "width": 120, "height": 61, "aspect": "variable", "svgPath": "M 0 10 L 120 10 L 120 51 L 0 51 Z M 0 10 A 60 10 0 0 1 120 10"},
{"name": "pump", "displayName": "Насос", "width": 36, "height": 33, "aspect": "variable", "svgPath": "M 0 16 L 36 0 L 36 33 L 0 16 Z"},
{"name": "pump_left", "displayName": "Насос (лев)", "width": 37, "height": 33, "aspect": "variable", "svgPath": "M 37 16 L 0 0 L 0 33 L 37 16 Z"},
{"name": "valve_horizontal", "displayName": "Клапан гориз.", "width": 40, "height": 32, "aspect": "variable", "svgPath": "M 0 8 L 20 20 L 0 32 Z M 40 8 L 20 20 L 40 32 Z"},
{"name": "valve_vertical", "displayName": "Клапан верт.", "width": 32, "height": 40, "aspect": "variable", "svgPath": "M 24 0 L 12 20 L 0 0 Z M 24 40 L 12 20 L 0 40 Z"},
{"name": "gate_valve", "displayName": "Задвижка", "width": 25, "height": 25, "aspect": "variable", "svgPath": "M 0 0 L 25 25 M 25 0 L 0 25 M 12 0 L 12 25"},
{"name": "compressor", "displayName": "Компрессор", "width": 66, "height": 47, "aspect": "variable", "svgPath": "M 0 0 L 66 0 L 66 47 L 0 47 Z M 10 10 L 56 10 L 56 37 L 10 37 Z"},
{"name": "fan", "displayName": "Вентилятор", "width": 40, "height": 40, "aspect": "variable", "svgPath": "M 20 0 A 20 20 0 1 0 20 40 A 20 20 0 1 0 20 0 Z M 20 10 L 30 20 L 20 30 L 10 20 Z"},
{"name": "conveyor", "displayName": "Конвейер", "width": 120, "height": 25, "aspect": "variable", "svgPath": "M 0 0 L 120 0 L 120 25 L 0 25 Z M 0 12 L 120 12"},
{"name": "mixer", "displayName": "Миксер", "width": 48, "height": 60, "aspect": "variable", "svgPath": "M 0 0 L 48 0 L 48 60 L 0 60 Z M 24 0 L 24 60"},
{"name": "bunker", "displayName": "Бункер", "width": 50, "height": 85, "aspect": "variable", "svgPath": "M 0 0 L 50 0 L 50 50 L 35 85 L 15 85 L 0 50 Z"},
{"name": "cyclone", "displayName": "Циклон", "width": 48, "height": 83, "aspect": "variable", "svgPath": "M 0 0 L 48 0 L 48 40 L 30 83 L 18 83 L 0 40 Z"},
{"name": "boiler", "displayName": "Котёл", "width": 58, "height": 112, "aspect": "variable", "svgPath": "M 4 0 L 54 0 A 4 4 0 0 1 54 8 L 54 104 A 4 4 0 0 1 50 112 L 8 112 A 4 4 0 0 1 4 104 Z"},
{"name": "filter", "displayName": "Фильтр", "width": 73, "height": 40, "aspect": "variable", "svgPath": "M 0 0 L 73 0 L 73 40 L 0 40 Z M 10 0 L 10 40 M 63 0 L 63 40"},
{"name": "separator", "displayName": "Сепаратор", "width": 79, "height": 29, "aspect": "variable", "svgPath": "M 0 0 L 79 0 L 79 29 L 0 29 Z"},
{"name": "elevator", "displayName": "Элеватор", "width": 30, "height": 100, "aspect": "variable", "svgPath": "M 0 0 L 30 0 L 30 100 L 0 100 Z M 0 15 A 15 15 0 0 1 30 15 M 0 85 A 15 15 0 0 0 30 85"},
{"name": "mill", "displayName": "Мельница", "width": 90, "height": 71, "aspect": "variable", "svgPath": "M 0 35 A 45 35 0 1 0 90 35 A 45 35 0 1 0 0 35 Z"},
{"name": "heat_exchanger", "displayName": "Теплообменник", "width": 50, "height": 51, "aspect": "variable", "svgPath": "M 0 0 L 50 0 L 50 51 L 0 51 Z M 0 25 L 50 25"},
{"name": "electric_motor", "displayName": "Эл. двигатель", "width": 40, "height": 40, "aspect": "variable", "svgPath": "M 20 0 A 20 20 0 1 0 20 40 A 20 20 0 1 0 20 0 Z M 12 14 L 28 14 L 20 28 Z"},
{"name": "thickener", "displayName": "Сгуститель", "width": 80, "height": 22, "aspect": "variable", "svgPath": "M 0 0 L 80 0 L 70 22 L 10 22 Z"},
{"name": "chamber_pump", "displayName": "Камерный насос", "width": 29, "height": 29, "aspect": "variable", "svgPath": "M 14 0 A 14 14 0 1 0 14 29 A 14 14 0 1 0 14 0 Z"},
{"name": "crusher_cone", "displayName": "Дробилка конус.", "width": 56, "height": 57, "aspect": "variable", "svgPath": "M 0 0 L 56 0 L 38 57 L 18 57 Z M 20 20 L 36 20 L 28 50 Z"},
{"name": "scrubber", "displayName": "Скруббер", "width": 61, "height": 100, "aspect": "variable", "svgPath": "M 0 0 L 61 0 L 61 100 L 0 100 Z M 10 30 L 51 30 M 10 60 L 51 60"},
{"name": "autoclave", "displayName": "Автоклав", "width": 32, "height": 100, "aspect": "variable", "svgPath": "M 0 15 A 16 15 0 0 1 32 15 L 32 85 A 16 15 0 0 1 0 85 Z"},
{"name": "deaerator", "displayName": "Деаэратор", "width": 100, "height": 40, "aspect": "variable", "svgPath": "M 10 0 L 90 0 A 10 20 0 0 1 90 40 L 10 40 A 10 20 0 0 1 10 0 Z"},
{"name": "centrifuge", "displayName": "Центрифуга", "width": 96, "height": 33, "aspect": "variable", "svgPath": "M 0 0 L 96 0 L 96 33 L 0 33 Z M 48 0 L 48 33"},
]
_FALLBACK_WIDGETS: list[dict[str, Any]] = [
{"name": "dynamic_status", "displayName": "Статус", "category": "indicator", "width": 32, "height": 32},
{"name": "dynamic_status_index", "displayName": "Статус индекс", "category": "indicator", "width": 32, "height": 32},
{"name": "dynamic_string_1", "displayName": "Строка 1", "category": "value", "width": 110, "height": 28},
{"name": "dynamic_string_2", "displayName": "Строка 2", "category": "value", "width": 140, "height": 28},
{"name": "dynamic_level", "displayName": "Уровень", "category": "value", "width": 56, "height": 120},
{"name": "dynamic_level_horizontal", "displayName": "Уровень гориз.", "category": "value", "width": 120, "height": 56},
{"name": "dynamic_pump", "displayName": "Насос", "category": "equipment", "width": 72, "height": 48},
{"name": "dynamic_pump_left", "displayName": "Насос (лев)", "category": "equipment", "width": 72, "height": 48},
{"name": "dynamic_chamber_pump", "displayName": "Камерный насос", "category": "equipment", "width": 60, "height": 48},
{"name": "dynamic_gate_valve", "displayName": "Задвижка", "category": "valve", "width": 68, "height": 44},
{"name": "dynamic_valve_horizontal", "displayName": "Клапан гориз.", "category": "valve", "width": 68, "height": 44},
{"name": "dynamic_valve_vertical", "displayName": "Клапан верт.", "category": "valve", "width": 44, "height": 68},
{"name": "dynamic_compressor", "displayName": "Компрессор", "category": "equipment", "width": 80, "height": 56},
{"name": "dynamic_fan", "displayName": "Вентилятор", "category": "equipment", "width": 56, "height": 56},
{"name": "dynamic_conveyor", "displayName": "Конвейер", "category": "equipment", "width": 140, "height": 32},
{"name": "dynamic_mixer", "displayName": "Миксер", "category": "equipment", "width": 80, "height": 80},
{"name": "dynamic_fire", "displayName": "Горелка", "category": "indicator", "width": 48, "height": 24},
{"name": "dynamic_equipment", "displayName": "Оборудование", "category": "equipment", "width": 80, "height": 60},
{"name": "dynamic_equipment_vertical", "displayName": "Оборуд. верт.", "category": "equipment", "width": 60, "height": 80},
{"name": "dynamic_analog_value_table_0", "displayName": "Таблица знач. 0", "category": "table", "width": 160, "height": 80},
{"name": "dynamic_analog_value_table_1", "displayName": "Таблица знач. 1", "category": "table", "width": 160, "height": 80},
{"name": "dynamic_analog_value_table_2", "displayName": "Таблица знач. 2", "category": "table", "width": 160, "height": 80},
{"name": "dynamic_analog_value_uom_0", "displayName": "Значение UOM 0", "category": "value", "width": 140, "height": 36},
{"name": "dynamic_analog_value_uom_2", "displayName": "Значение UOM 2", "category": "value", "width": 140, "height": 36},
{"name": "dynamic_dict", "displayName": "Словарь", "category": "indicator", "width": 100, "height": 28},
{"name": "dynamic_tachometer", "displayName": "Тахометр", "category": "indicator", "width": 60, "height": 60},
{"name": "dynamic_arrow", "displayName": "Стрелка", "category": "indicator", "width": 40, "height": 46},
]
@lru_cache(maxsize=1)
def build_element_catalog(
widget_json_path: str | None = None,
statics_xml_path: str | None = None,
) -> dict[str, Any]:
"""Build the full element catalog for the frontend.
Returns a dict with 'statics', 'widgets', and 'basics' lists.
Widget XML is NOT included — only metadata.
"""
w_path = Path(widget_json_path) if widget_json_path else _DEFAULT_WIDGET_JSON
s_path = Path(statics_xml_path) if statics_xml_path else _DEFAULT_STATICS_XML
statics = _load_statics_catalog(s_path)
widgets = _load_widgets_catalog(w_path)
if not statics:
statics = list(_FALLBACK_STATICS)
if not widgets:
widgets = list(_FALLBACK_WIDGETS)
return {
"statics": statics,
"widgets": widgets,
"basics": list(_BASIC_ELEMENTS),
}