Spaces:
Paused
Paused
| """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}, | |
| ] | |
| 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), | |
| } | |