"""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 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), }