Skip to main content
Glama
layout_learner.py14.3 kB
""" Layout Learner Module Learns and applies user's preferred Grasshopper canvas layout patterns. """ import json import os import gzip import xml.etree.ElementTree as ET from datetime import datetime from typing import Optional, Tuple, List, Dict, Any from pathlib import Path class LayoutLearner: """ Learns layout patterns from GH files and live canvas, then applies them when creating new components. """ DEFAULT_PREFERENCES = { "user_id": "default", "last_updated": None, "samples_count": 0, "preferences": { "spacing_x": 200, "spacing_y": 80, "flow_direction": "left_to_right", "grid_aligned": True, "input_position": "left", "wire_style": "horizontal" }, "component_patterns": {}, "spacing_samples": { "x": [], "y": [] } } def __init__(self, preferences_path: Optional[str] = None): if preferences_path is None: # Default path next to this file self.preferences_path = Path(__file__).parent / "layout_preferences.json" else: self.preferences_path = Path(preferences_path) self.preferences = self.load_preferences() def load_preferences(self) -> dict: """Load preferences from JSON file""" if self.preferences_path.exists(): try: with open(self.preferences_path, 'r', encoding='utf-8') as f: return json.load(f) except (json.JSONDecodeError, IOError): pass return self.DEFAULT_PREFERENCES.copy() def save_preferences(self): """Save preferences to JSON file""" self.preferences["last_updated"] = datetime.now().isoformat() with open(self.preferences_path, 'w', encoding='utf-8') as f: json.dump(self.preferences, f, indent=2, ensure_ascii=False) def analyze_gh_file(self, file_path: str) -> dict: """ Analyze a GH file and extract layout patterns. Args: file_path: Path to .gh or .ghx file Returns: Dictionary with extracted patterns """ file_path = Path(file_path) if not file_path.exists(): return {"error": f"File not found: {file_path}"} try: # Read file content (GH files are gzipped XML) if file_path.suffix.lower() == '.gh': with gzip.open(file_path, 'rt', encoding='utf-8') as f: content = f.read() else: # .ghx is plain XML with open(file_path, 'r', encoding='utf-8') as f: content = f.read() root = ET.fromstring(content) # Extract component positions and connections components = self._extract_components(root) wires = self._extract_wires(root) # Calculate patterns patterns = self._calculate_patterns(components, wires) # Update preferences with new data self._update_from_patterns(patterns) return { "success": True, "file": str(file_path), "components_found": len(components), "wires_found": len(wires), "patterns": patterns } except Exception as e: return {"error": str(e)} def _extract_components(self, root: ET.Element) -> List[dict]: """Extract component positions from XML""" components = [] # Find all objects with pivot points for obj in root.iter(): if obj.tag == "Object": comp_data = { "guid": obj.get("Id", ""), "name": obj.get("Name", ""), } # Find pivot position pivot = obj.find(".//Pivot") if pivot is not None: x = pivot.get("X") or pivot.find("X") y = pivot.get("Y") or pivot.find("Y") if x is not None and y is not None: try: comp_data["x"] = float(x.text if hasattr(x, 'text') else x) comp_data["y"] = float(y.text if hasattr(y, 'text') else y) components.append(comp_data) except (ValueError, TypeError): pass return components def _extract_wires(self, root: ET.Element) -> List[dict]: """Extract wire connections from XML""" wires = [] for wire in root.iter("Wire"): source = wire.find("Source") target = wire.find("Target") if source is not None and target is not None: wires.append({ "source_guid": source.get("Id", ""), "target_guid": target.get("Id", "") }) return wires def _calculate_patterns(self, components: List[dict], wires: List[dict]) -> dict: """Calculate layout patterns from components and wires""" if len(components) < 2: return {} # Build GUID to component map guid_map = {c["guid"]: c for c in components if "guid" in c} # Calculate spacing between connected components x_spacings = [] y_spacings = [] flow_directions = {"left_to_right": 0, "right_to_left": 0, "top_to_bottom": 0} for wire in wires: source = guid_map.get(wire["source_guid"]) target = guid_map.get(wire["target_guid"]) if source and target and "x" in source and "x" in target: dx = target["x"] - source["x"] dy = target["y"] - source["y"] x_spacings.append(abs(dx)) y_spacings.append(abs(dy)) # Determine flow direction if abs(dx) > abs(dy): if dx > 0: flow_directions["left_to_right"] += 1 else: flow_directions["right_to_left"] += 1 else: flow_directions["top_to_bottom"] += 1 # Calculate averages patterns = {} if x_spacings: patterns["avg_spacing_x"] = sum(x_spacings) / len(x_spacings) if y_spacings: patterns["avg_spacing_y"] = sum(y_spacings) / len(y_spacings) # Determine dominant flow direction if flow_directions: patterns["flow_direction"] = max(flow_directions, key=flow_directions.get) # Check grid alignment if components: x_coords = [c["x"] for c in components if "x" in c] y_coords = [c["y"] for c in components if "y" in c] # Check if coordinates cluster around regular intervals patterns["grid_aligned"] = self._check_grid_alignment(x_coords, y_coords) return patterns def _check_grid_alignment(self, x_coords: List[float], y_coords: List[float]) -> bool: """Check if coordinates suggest grid-like alignment""" if len(x_coords) < 3: return True # Assume grid for small sets # Check variance in spacing x_sorted = sorted(x_coords) spacings = [x_sorted[i+1] - x_sorted[i] for i in range(len(x_sorted)-1)] if not spacings: return True avg_spacing = sum(spacings) / len(spacings) if avg_spacing == 0: return True # Calculate coefficient of variation variance = sum((s - avg_spacing) ** 2 for s in spacings) / len(spacings) std_dev = variance ** 0.5 cv = std_dev / avg_spacing if avg_spacing else 0 # If CV is low, spacing is consistent (grid-like) return cv < 0.5 def _update_from_patterns(self, patterns: dict): """Update preferences based on extracted patterns""" if not patterns: return prefs = self.preferences["preferences"] samples = self.preferences.get("spacing_samples", {"x": [], "y": []}) # Add new spacing samples if "avg_spacing_x" in patterns: samples["x"].append(patterns["avg_spacing_x"]) # Keep last 50 samples samples["x"] = samples["x"][-50:] prefs["spacing_x"] = sum(samples["x"]) / len(samples["x"]) if "avg_spacing_y" in patterns: samples["y"].append(patterns["avg_spacing_y"]) samples["y"] = samples["y"][-50:] prefs["spacing_y"] = sum(samples["y"]) / len(samples["y"]) if "flow_direction" in patterns: prefs["flow_direction"] = patterns["flow_direction"] if "grid_aligned" in patterns: prefs["grid_aligned"] = patterns["grid_aligned"] self.preferences["spacing_samples"] = samples self.preferences["samples_count"] += 1 self.save_preferences() def analyze_canvas(self, components: List[dict]) -> dict: """ Analyze current canvas state and extract patterns. Args: components: List of component dicts with name, x, y, guid, etc. Returns: Extracted patterns """ if len(components) < 2: return {} # Calculate spacing between adjacent components (by x position) sorted_by_x = sorted(components, key=lambda c: c.get("x", 0)) x_spacings = [] for i in range(len(sorted_by_x) - 1): dx = sorted_by_x[i+1].get("x", 0) - sorted_by_x[i].get("x", 0) if dx > 0: x_spacings.append(dx) # Calculate y spacing for vertically adjacent components sorted_by_y = sorted(components, key=lambda c: c.get("y", 0)) y_spacings = [] for i in range(len(sorted_by_y) - 1): dy = sorted_by_y[i+1].get("y", 0) - sorted_by_y[i].get("y", 0) if dy > 0: y_spacings.append(dy) patterns = {} if x_spacings: patterns["avg_spacing_x"] = sum(x_spacings) / len(x_spacings) if y_spacings: patterns["avg_spacing_y"] = sum(y_spacings) / len(y_spacings) # Update preferences self._update_from_patterns(patterns) return patterns def get_next_position( self, component_name: str, connected_to: Optional[dict] = None, direction: str = "right" ) -> Tuple[float, float]: """ Calculate the next component position based on learned patterns. Args: component_name: Name of the component to place connected_to: Component dict this will connect to (with x, y) direction: Direction relative to connected component ("right", "below", "left", "above") Returns: Tuple of (x, y) coordinates """ prefs = self.preferences["preferences"] spacing_x = prefs.get("spacing_x", 200) spacing_y = prefs.get("spacing_y", 80) # Check for component-specific patterns comp_patterns = self.preferences.get("component_patterns", {}) if component_name in comp_patterns: offset = comp_patterns[component_name] if connected_to and "x" in connected_to and "y" in connected_to: return ( connected_to["x"] + offset.get("offset_x", spacing_x), connected_to["y"] + offset.get("offset_y", 0) ) # Default positioning based on direction if connected_to and "x" in connected_to and "y" in connected_to: base_x = connected_to["x"] base_y = connected_to["y"] if direction == "right": return (base_x + spacing_x, base_y) elif direction == "below": return (base_x, base_y + spacing_y) elif direction == "left": return (base_x - spacing_x, base_y) elif direction == "above": return (base_x, base_y - spacing_y) # No reference component, start at origin return (0, 0) def learn_component_pattern( self, component_name: str, offset_x: float, offset_y: float ): """ Learn a specific offset pattern for a component type. Args: component_name: Name of the component offset_x: X offset from connected component offset_y: Y offset from connected component """ if "component_patterns" not in self.preferences: self.preferences["component_patterns"] = {} # Running average for the component existing = self.preferences["component_patterns"].get(component_name, {}) count = existing.get("count", 0) if count > 0: # Update running average old_x = existing.get("offset_x", 0) old_y = existing.get("offset_y", 0) new_x = (old_x * count + offset_x) / (count + 1) new_y = (old_y * count + offset_y) / (count + 1) else: new_x = offset_x new_y = offset_y self.preferences["component_patterns"][component_name] = { "offset_x": new_x, "offset_y": new_y, "count": count + 1 } self.save_preferences() def get_preferences_summary(self) -> dict: """Get a summary of current learned preferences""" prefs = self.preferences["preferences"] return { "spacing_x": round(prefs.get("spacing_x", 200), 1), "spacing_y": round(prefs.get("spacing_y", 80), 1), "flow_direction": prefs.get("flow_direction", "left_to_right"), "grid_aligned": prefs.get("grid_aligned", True), "samples_count": self.preferences.get("samples_count", 0), "component_patterns_count": len(self.preferences.get("component_patterns", {})) } # Singleton instance _learner: Optional[LayoutLearner] = None def get_learner(preferences_path: Optional[str] = None) -> LayoutLearner: """Get or create the layout learner instance""" global _learner if _learner is None: _learner = LayoutLearner(preferences_path) return _learner

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dongwoosuk/grasshopper-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server