Skip to main content
Glama
addon.py83.6 kB
import bpy import mathutils import json import threading import socket import time import requests import tempfile import traceback import os import shutil from bpy.props import StringProperty, IntProperty, BoolProperty, EnumProperty import base64 import bpy import ifcopenshell from bonsai.bim.ifc import IfcStore bl_info = { "name": "Bonsai MCP", "author": "JotaDeRodriguez", "version": (0, 2), "blender": (3, 0, 0), "location": "View3D > Sidebar > Bonsai MCP", "description": "Connect Claude to Blender via MCP. Aimed at IFC projects", "category": "Interface", } class BlenderMCPServer: def __init__(self, host='localhost', port=9876): self.host = host self.port = port self.running = False self.socket = None self.server_thread = None def start(self): if self.running: print("Server is already running") return self.running = True try: # Create socket self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind((self.host, self.port)) self.socket.listen(1) # Start server thread self.server_thread = threading.Thread(target=self._server_loop) self.server_thread.daemon = True self.server_thread.start() print(f"BlenderMCP server started on {self.host}:{self.port}") except Exception as e: print(f"Failed to start server: {str(e)}") self.stop() def stop(self): self.running = False # Close socket if self.socket: try: self.socket.close() except: pass self.socket = None # Wait for thread to finish if self.server_thread: try: if self.server_thread.is_alive(): self.server_thread.join(timeout=1.0) except: pass self.server_thread = None print("BlenderMCP server stopped") def _server_loop(self): """Main server loop in a separate thread""" print("Server thread started") self.socket.settimeout(1.0) # Timeout to allow for stopping while self.running: try: # Accept new connection try: client, address = self.socket.accept() print(f"Connected to client: {address}") # Handle client in a separate thread client_thread = threading.Thread( target=self._handle_client, args=(client,) ) client_thread.daemon = True client_thread.start() except socket.timeout: # Just check running condition continue except Exception as e: print(f"Error accepting connection: {str(e)}") time.sleep(0.5) except Exception as e: print(f"Error in server loop: {str(e)}") if not self.running: break time.sleep(0.5) print("Server thread stopped") def _handle_client(self, client): """Handle connected client""" print("Client handler started") client.settimeout(None) # No timeout buffer = b'' try: while self.running: # Receive data try: data = client.recv(8192) if not data: print("Client disconnected") break buffer += data try: # Try to parse command command = json.loads(buffer.decode('utf-8')) buffer = b'' # Execute command in Blender's main thread def execute_wrapper(): try: response = self.execute_command(command) response_json = json.dumps(response) try: client.sendall(response_json.encode('utf-8')) except: print("Failed to send response - client disconnected") except Exception as e: print(f"Error executing command: {str(e)}") traceback.print_exc() try: error_response = { "status": "error", "message": str(e) } client.sendall(json.dumps(error_response).encode('utf-8')) except: pass return None # Schedule execution in main thread bpy.app.timers.register(execute_wrapper, first_interval=0.0) except json.JSONDecodeError: # Incomplete data, wait for more pass except Exception as e: print(f"Error receiving data: {str(e)}") break except Exception as e: print(f"Error in client handler: {str(e)}") finally: try: client.close() except: pass print("Client handler stopped") def execute_command(self, command): """Execute a command in the main Blender thread""" try: cmd_type = command.get("type") params = command.get("params", {}) # Ensure we're in the right context if cmd_type in ["create_object", "modify_object", "delete_object"]: override = bpy.context.copy() override['area'] = [area for area in bpy.context.screen.areas if area.type == 'VIEW_3D'][0] with bpy.context.temp_override(**override): return self._execute_command_internal(command) else: return self._execute_command_internal(command) except Exception as e: print(f"Error executing command: {str(e)}") traceback.print_exc() return {"status": "error", "message": str(e)} def _execute_command_internal(self, command): """Internal command execution with proper context""" cmd_type = command.get("type") params = command.get("params", {}) # Base handlers that are always available handlers = { "execute_code": self.execute_code, "get_ifc_project_info": self.get_ifc_project_info, "list_ifc_entities": self.list_ifc_entities, "get_ifc_properties": self.get_ifc_properties, "get_ifc_spatial_structure": self.get_ifc_spatial_structure, "get_ifc_relationships": self.get_ifc_relationships, "get_selected_ifc_entities": self.get_selected_ifc_entities, "get_current_view": self.get_current_view, "export_ifc_data": self.export_ifc_data, "place_ifc_object": self.place_ifc_object, "get_ifc_quantities": self.get_ifc_quantities, "export_drawing_png": self.export_drawing_png, "get_ifc_georeferencing_info": self.get_ifc_georeferencing_info, "georeference_ifc_model": self.georeference_ifc_model, } handler = handlers.get(cmd_type) if handler: try: print(f"Executing handler for {cmd_type}") result = handler(**params) print(f"Handler execution complete") return {"status": "success", "result": result} except Exception as e: print(f"Error in handler: {str(e)}") traceback.print_exc() return {"status": "error", "message": str(e)} else: return {"status": "error", "message": f"Unknown command type: {cmd_type}"} def execute_code(self, code): """Execute arbitrary Blender Python code""" # This is powerful but potentially dangerous - use with caution try: # Create a local namespace for execution namespace = {"bpy": bpy} exec(code, namespace) return {"executed": True} except Exception as e: raise Exception(f"Code execution error: {str(e)}") @staticmethod def get_selected_ifc_entities(): """ Get the IFC entities corresponding to the currently selected Blender objects. Returns: List of IFC entities for the selected objects """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # Get currently selected objects selected_objects = bpy.context.selected_objects if not selected_objects: return {"selected_count": 0, "message": "No objects selected in Blender"} # Collect IFC entities from selected objects selected_entities = [] for obj in selected_objects: if hasattr(obj, "BIMObjectProperties") and obj.BIMObjectProperties.ifc_definition_id: entity_id = obj.BIMObjectProperties.ifc_definition_id entity = file.by_id(entity_id) if entity: entity_info = { "id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}", "ifc_id": entity.id(), "type": entity.is_a(), "name": entity.Name if hasattr(entity, "Name") else None, "blender_name": obj.name } selected_entities.append(entity_info) return { "selected_count": len(selected_entities), "selected_entities": selected_entities } except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} ### SPECIFIC IFC METHODS ### @staticmethod def get_ifc_project_info(): """ Get basic information about the IFC project. Returns: Dictionary with project name, description, and basic metrics """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # Get project information projects = file.by_type("IfcProject") if not projects: return {"error": "No IfcProject found in the model"} project = projects[0] # Basic project info info = { "id": project.GlobalId, "name": project.Name if hasattr(project, "Name") else "Unnamed Project", "description": project.Description if hasattr(project, "Description") else None, "entity_counts": {} } # Count entities by type entity_types = ["IfcWall", "IfcDoor", "IfcWindow", "IfcSlab", "IfcBeam", "IfcColumn", "IfcSpace", "IfcBuildingStorey"] for entity_type in entity_types: entities = file.by_type(entity_type) info["entity_counts"][entity_type] = len(entities) return info except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def list_ifc_entities(entity_type=None, limit=50, selected_only=False): """ List IFC entities of a specific type. Parameters: entity_type: Type of IFC entity to list (e.g., "IfcWall") limit: Maximum number of entities to return Returns: List of entities with basic properties """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # If we're only looking at selected objects if selected_only: selected_result = BlenderMCPServer.get_selected_ifc_entities() # Check for errors if "error" in selected_result: return selected_result # If no objects are selected, return early if selected_result["selected_count"] == 0: return selected_result # If entity_type is specified, filter the selected entities if entity_type: filtered_entities = [ entity for entity in selected_result["selected_entities"] if entity["type"] == entity_type ] return { "type": entity_type, "selected_count": len(filtered_entities), "entities": filtered_entities[:limit] } else: # Group selected entities by type entity_types = {} for entity in selected_result["selected_entities"]: entity_type = entity["type"] if entity_type in entity_types: entity_types[entity_type].append(entity) else: entity_types[entity_type] = [entity] return { "selected_count": selected_result["selected_count"], "entity_types": [ {"type": t, "count": len(entities), "entities": entities[:limit]} for t, entities in entity_types.items() ] } # Original functionality for non-selected mode if not entity_type: # If no type specified, list available entity types entity_types = {} for entity in file.wrapped_data.entities: entity_type = entity.is_a() if entity_type in entity_types: entity_types[entity_type] += 1 else: entity_types[entity_type] = 1 return { "available_types": [{"type": k, "count": v} for k, v in entity_types.items()] } # Get entities of the specified type entities = file.by_type(entity_type) # Prepare the result result = { "type": entity_type, "total_count": len(entities), "entities": [] } # Add entity data (limited) for i, entity in enumerate(entities): if i >= limit: break entity_data = { "id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}", "name": entity.Name if hasattr(entity, "Name") else None } result["entities"].append(entity_data) return result except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def get_ifc_properties(global_id=None, selected_only=False): """ Get all properties of a specific IFC entity. Parameters: global_id: GlobalId of the IFC entity Returns: Dictionary with entity information and properties """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # If we're only looking at selected objects if selected_only: selected_result = BlenderMCPServer.get_selected_ifc_entities() # Check for errors if "error" in selected_result: return selected_result # If no objects are selected, return early if selected_result["selected_count"] == 0: return selected_result # Process each selected entity result = { "selected_count": selected_result["selected_count"], "entities": [] } for entity_info in selected_result["selected_entities"]: # Find entity by GlobalId entity = file.by_guid(entity_info["id"]) if not entity: continue # Get basic entity info entity_data = { "id": entity.GlobalId, "type": entity.is_a(), "name": entity.Name if hasattr(entity, "Name") else None, "description": entity.Description if hasattr(entity, "Description") else None, "blender_name": entity_info["blender_name"], "property_sets": {} } # Get all property sets psets = ifcopenshell.util.element.get_psets(entity) for pset_name, pset_data in psets.items(): entity_data["property_sets"][pset_name] = pset_data result["entities"].append(entity_data) return result # If we're looking at a specific entity elif global_id: # Find entity by GlobalId entity = file.by_guid(global_id) if not entity: return {"error": f"No entity found with GlobalId: {global_id}"} # Get basic entity info entity_info = { "id": entity.GlobalId, "type": entity.is_a(), "name": entity.Name if hasattr(entity, "Name") else None, "description": entity.Description if hasattr(entity, "Description") else None, "property_sets": {} } # Get all property sets psets = ifcopenshell.util.element.get_psets(entity) for pset_name, pset_data in psets.items(): entity_info["property_sets"][pset_name] = pset_data return entity_info else: return {"error": "Either global_id or selected_only must be specified"} except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def get_ifc_spatial_structure(): """ Get the spatial structure of the IFC model (site, building, storey, space hierarchy). Returns: Hierarchical structure of the IFC model's spatial elements """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # Start with projects projects = file.by_type("IfcProject") if not projects: return {"error": "No IfcProject found in the model"} def get_children(parent): """Get immediate children of the given element""" if hasattr(parent, "IsDecomposedBy"): rel_aggregates = parent.IsDecomposedBy children = [] for rel in rel_aggregates: children.extend(rel.RelatedObjects) return children return [] def create_structure(element): """Recursively create the structure for an element""" result = { "id": element.GlobalId, "type": element.is_a(), "name": element.Name if hasattr(element, "Name") else None, "children": [] } for child in get_children(element): result["children"].append(create_structure(child)) return result # Create the structure starting from the project structure = create_structure(projects[0]) return structure except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def get_ifc_relationships(global_id): """ Get all relationships for a specific IFC entity. Parameters: global_id: GlobalId of the IFC entity Returns: Dictionary with all relationships the entity participates in """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # Find entity by GlobalId entity = file.by_guid(global_id) if not entity: return {"error": f"No entity found with GlobalId: {global_id}"} # Basic entity info entity_info = { "id": entity.GlobalId, "type": entity.is_a(), "name": entity.Name if hasattr(entity, "Name") else None, "relationships": { "contains": [], "contained_in": [], "connects": [], "connected_by": [], "defines": [], "defined_by": [] } } # Check if entity contains other elements if hasattr(entity, "IsDecomposedBy"): for rel in entity.IsDecomposedBy: for obj in rel.RelatedObjects: entity_info["relationships"]["contains"].append({ "id": obj.GlobalId, "type": obj.is_a(), "name": obj.Name if hasattr(obj, "Name") else None }) # Check if entity is contained in other elements if hasattr(entity, "Decomposes"): for rel in entity.Decomposes: rel_obj = rel.RelatingObject entity_info["relationships"]["contained_in"].append({ "id": rel_obj.GlobalId, "type": rel_obj.is_a(), "name": rel_obj.Name if hasattr(rel_obj, "Name") else None }) # For physical connections (depends on entity type) if hasattr(entity, "ConnectedTo"): for rel in entity.ConnectedTo: for obj in rel.RelatedElement: entity_info["relationships"]["connects"].append({ "id": obj.GlobalId, "type": obj.is_a(), "name": obj.Name if hasattr(obj, "Name") else None, "connection_type": rel.ConnectionType if hasattr(rel, "ConnectionType") else None }) if hasattr(entity, "ConnectedFrom"): for rel in entity.ConnectedFrom: obj = rel.RelatingElement entity_info["relationships"]["connected_by"].append({ "id": obj.GlobalId, "type": obj.is_a(), "name": obj.Name if hasattr(obj, "Name") else None, "connection_type": rel.ConnectionType if hasattr(rel, "ConnectionType") else None }) return entity_info except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def export_ifc_data(entity_type=None, level_name=None, output_format="csv"): """Export IFC data to a structured file""" try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} data_list = [] # Filter objects based on type if entity_type: objects = file.by_type(entity_type) else: objects = file.by_type("IfcElement") # Create a data dictionary for each object for obj in objects: obj_data = {} # Get level/storey information container_level = None try: containing_structure = ifcopenshell.util.element.get_container(obj) if containing_structure and containing_structure.is_a("IfcBuildingStorey"): container_level = containing_structure.Name except Exception as e: pass # Skip if we're filtering by level and this doesn't match if level_name and container_level != level_name: continue # Basic information obj_data['ExpressId'] = obj.id() obj_data['GlobalId'] = obj.GlobalId if hasattr(obj, "GlobalId") else None obj_data['IfcClass'] = obj.is_a() obj_data['Name'] = obj.Name if hasattr(obj, "Name") else None obj_data['Description'] = obj.Description if hasattr(obj, "Description") else None obj_data['LevelName'] = container_level # Get predefined type if available try: obj_data['PredefinedType'] = ifcopenshell.util.element.get_predefined_type(obj) except: obj_data['PredefinedType'] = None # Get type information try: type_obj = ifcopenshell.util.element.get_type(obj) obj_data['TypeName'] = type_obj.Name if type_obj and hasattr(type_obj, "Name") else None obj_data['TypeClass'] = type_obj.is_a() if type_obj else None except: obj_data['TypeName'] = None obj_data['TypeClass'] = None # Get property sets (simplify structure for export) try: property_sets = ifcopenshell.util.element.get_psets(obj) # Flatten property sets for better export compatibility for pset_name, pset_data in property_sets.items(): for prop_name, prop_value in pset_data.items(): obj_data[f"{pset_name}.{prop_name}"] = prop_value except Exception as e: pass data_list.append(obj_data) if not data_list: return "No data found matching the specified criteria" # Determine output directory - try multiple options to ensure it works in various environments output_dirs = [ "C:\\Users\\Public\\Documents" if os.name == "nt" else None, # Public Documents "/usr/share" if os.name != "nt" else None, # Unix share directory "/tmp", # Unix temp directory "C:\\Temp" if os.name == "nt" else None, # Windows temp directory ] output_dir = None for dir_path in output_dirs: if dir_path and os.path.exists(dir_path) and os.access(dir_path, os.W_OK): output_dir = dir_path break if not output_dir: return {"error": "Could not find a writable directory for output"} # Create filename based on filters filters = [] if entity_type: filters.append(entity_type) if level_name: filters.append(level_name) filter_str = "_".join(filters) if filters else "all" timestamp = time.strftime("%Y%m%d_%H%M%S") filename = f"ifc_export_{filter_str}_{timestamp}.{output_format}" filepath = os.path.join(output_dir, filename) # Export based on format if output_format == "json": with open(filepath, 'w') as f: json.dump(data_list, f, indent=2) elif output_format == "csv": import pandas as pd df = pd.DataFrame(data_list) df.to_csv(filepath, index=False) # Summary info for the response entity_count = len(data_list) entity_types = set(item['IfcClass'] for item in data_list) levels = set(item['LevelName'] for item in data_list if item['LevelName']) return { "success": True, "message": f"Data exported successfully to {filepath}", "filepath": filepath, "format": output_format, "summary": { "entity_count": entity_count, "entity_types": list(entity_types), "levels": list(levels) } } except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def place_ifc_object(type_name, location, rotation=None): """ Place an IFC object at specified location with optional rotation Args: type_name: Name of the IFC element type location: [x, y, z] list or tuple for position rotation: Value in degrees for rotation around Z axis (optional) Returns: Dictionary with information about the created object """ try: import ifcopenshell from bonsai.bim.ifc import IfcStore import math # Convert location to tuple if it's not already if isinstance(location, list): location = tuple(location) def find_type_by_name(name): file = IfcStore.get_file() for element in file.by_type("IfcElementType"): if element.Name == name: return element.id() return None # Find the type ID type_id = find_type_by_name(type_name) if not type_id: return {"error": f"Type '{type_name}' not found. Please check if this type exists in the model."} # Store original context original_context = bpy.context.copy() # Ensure we're in 3D View context override = bpy.context.copy() for area in bpy.context.screen.areas: if area.type == 'VIEW_3D': override["area"] = area override["region"] = area.regions[-1] break # Set cursor location bpy.context.scene.cursor.location = location # Get properties to set up parameters props = bpy.context.scene.BIMModelProperties # Store original rl_mode and set to CURSOR to use cursor's Z position original_rl_mode = props.rl_mode props.rl_mode = 'CURSOR' # Create the object using the override context with bpy.context.temp_override(**override): bpy.ops.bim.add_occurrence(relating_type_id=type_id) # Get the newly created object obj = bpy.context.active_object if not obj: props.rl_mode = original_rl_mode return {"error": "Failed to create object"} # Force the Z position explicitly obj.location.z = location[2] # Apply rotation if provided if rotation is not None: # Convert degrees to radians for Blender's rotation_euler full_rotation = (0, 0, math.radians(float(rotation))) obj.rotation_euler = full_rotation # Sync the changes back to IFC # Use the appropriate method depending on what's available if hasattr(bpy.ops.bim, "update_representation"): bpy.ops.bim.update_representation(obj=obj.name) # Restore original rl_mode props.rl_mode = original_rl_mode # Get the IFC entity for the new object entity_id = obj.BIMObjectProperties.ifc_definition_id if entity_id: file = IfcStore.get_file() entity = file.by_id(entity_id) global_id = entity.GlobalId if hasattr(entity, "GlobalId") else None else: global_id = None # Return information about the created object return { "success": True, "blender_name": obj.name, "global_id": global_id, "location": list(obj.location), "rotation": list(obj.rotation_euler), "type_name": type_name } except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} ### Ability to see @staticmethod def get_current_view(): """Capture and return the current viewport as an image""" try: # Find a 3D View for area in bpy.context.screen.areas: if area.type == 'VIEW_3D': break else: return {"error": "No 3D View available"} # Create temporary file to save the viewport screenshot temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False) temp_path = temp_file.name temp_file.close() # Find appropriate region for region in area.regions: if region.type == 'WINDOW': break else: return {"error": "No appropriate region found in 3D View"} # Use temp_override instead of the old override dictionary with bpy.context.temp_override(area=area, region=region): # Save screenshot bpy.ops.screen.screenshot(filepath=temp_path) # Read the image data and encode as base64 with open(temp_path, 'rb') as f: image_data = f.read() # Clean up os.unlink(temp_path) # Return base64 encoded image return { "width": area.width, "height": area.height, "format": "png", "data": base64.b64encode(image_data).decode('utf-8') } except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def get_ifc_quantities(entity_type=None, selected_only=False): """ Calculate and get quantities (m2, m3, etc.) for IFC elements. Parameters: entity_type: Type of IFC entity to get quantities for (e.g., "IfcWall", "IfcSlab") selected_only: If True, only get quantities for selected objects Returns: Dictionary with quantities for the specified elements """ try: file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # First, calculate all quantities try: bpy.ops.bim.perform_quantity_take_off() except Exception as e: return {"error": f"Failed to calculate quantities: {str(e)}"} elements_data = [] # If we're only looking at selected objects if selected_only: selected_result = BlenderMCPServer.get_selected_ifc_entities() # Check for errors if "error" in selected_result: return selected_result # If no objects are selected, return early if selected_result["selected_count"] == 0: return selected_result # Process each selected entity for entity_info in selected_result["selected_entities"]: # Find entity by GlobalId entity = file.by_guid(entity_info["id"]) if not entity: continue # Filter by type if specified if entity_type and entity.is_a() != entity_type: continue # Extract quantities element_data = extract_quantities(entity, entity_info["blender_name"]) if element_data: elements_data.append(element_data) else: # Get entities based on type or default to common element types if entity_type: entities = file.by_type(entity_type) else: # Get common element types that have quantities entity_types = ["IfcWall", "IfcSlab", "IfcBeam", "IfcColumn", "IfcDoor", "IfcWindow"] entities = [] for etype in entity_types: entities.extend(file.by_type(etype)) # Process each entity for entity in entities: element_data = extract_quantities(entity) if element_data: elements_data.append(element_data) # Summary statistics summary = { "total_elements": len(elements_data), "element_types": {} } # Group by element type for summary for element in elements_data: etype = element["type"] if etype not in summary["element_types"]: summary["element_types"][etype] = {"count": 0, "total_area": 0, "total_volume": 0} summary["element_types"][etype]["count"] += 1 if element["quantities"].get("area"): summary["element_types"][etype]["total_area"] += element["quantities"]["area"] if element["quantities"].get("volume"): summary["element_types"][etype]["total_volume"] += element["quantities"]["volume"] return { "success": True, "elements": elements_data, "summary": summary } except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def export_drawing_png(view_type="top", height_offset=0.5, resolution_x=1920, resolution_y=1080, storey_name=None, output_path=None): """ Export drawings as PNG images with custom resolution. Creates 2D and 3D views of IFC building, particularly useful for architectural drawings. Args: view_type: "top" for plan view, "front", "right", "left" for elevations, "isometric" for 3D view height_offset: Height in meters above storey level for camera position resolution_x: Horizontal resolution in pixels resolution_y: Vertical resolution in pixels storey_name: Specific storey name to render (None for all/ground floor) output_path: File path to save PNG (None for temp file) Returns: Dict with base64 encoded image data and metadata """ try: import tempfile import os # Validate parameters if resolution_x > 4096 or resolution_y > 4096: return {"error": "Resolution too high. Maximum: 4096x4096"} if resolution_x < 100 or resolution_y < 100: return {"error": "Resolution too low. Minimum: 100x100"} # Check if IFC file is loaded file = IfcStore.get_file() if file is None: return {"error": "No IFC file is currently loaded"} # Store original render settings scene = bpy.context.scene original_engine = scene.render.engine original_res_x = scene.render.resolution_x original_res_y = scene.render.resolution_y original_filepath = scene.render.filepath # Set up render settings for drawing scene.render.engine = 'BLENDER_WORKBENCH' # Fast, good for architectural drawings scene.render.resolution_x = resolution_x scene.render.resolution_y = resolution_y scene.render.resolution_percentage = 100 # Store original camera if exists original_camera = bpy.context.scene.camera # Create temporary camera for orthographic rendering bpy.ops.object.camera_add() camera = bpy.context.object camera.name = "TempDrawingCamera" bpy.context.scene.camera = camera # Set camera to orthographic camera.data.type = 'ORTHO' camera.data.ortho_scale = 50 # Adjust based on building size # Position camera based on view type and storey if view_type == "top": # Find building bounds to position camera appropriately all_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH' and obj.visible_get()] if all_objects: # Calculate bounding box of all visible objects min_x = min_y = min_z = float('inf') max_x = max_y = max_z = float('-inf') for obj in all_objects: bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box] for corner in bbox: min_x = min(min_x, corner.x) max_x = max(max_x, corner.x) min_y = min(min_y, corner.y) max_y = max(max_y, corner.y) min_z = min(min_z, corner.z) max_z = max(max_z, corner.z) # Position camera above the building center_x = (min_x + max_x) / 2 center_y = (min_y + max_y) / 2 # For plan view, position camera above camera_height = max_z + height_offset camera.location = (center_x, center_y, camera_height) camera.rotation_euler = (0, 0, 0) # Look down # Adjust orthographic scale based on building size building_width = max(max_x - min_x, max_y - min_y) * 1.2 # Add 20% margin camera.data.ortho_scale = building_width else: # Default position if no objects found camera.location = (0, 0, 10) camera.rotation_euler = (0, 0, 0) elif view_type in ["front", "right", "left"]: # For elevations, position camera accordingly # This is a simplified implementation - could be enhanced all_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH' and obj.visible_get()] if all_objects: # Calculate bounds min_x = min_y = min_z = float('inf') max_x = max_y = max_z = float('-inf') for obj in all_objects: bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box] for corner in bbox: min_x = min(min_x, corner.x) max_x = max(max_x, corner.x) min_y = min(min_y, corner.y) max_y = max(max_y, corner.y) min_z = min(min_z, corner.z) max_z = max(max_z, corner.z) center_x = (min_x + max_x) / 2 center_y = (min_y + max_y) / 2 center_z = (min_z + max_z) / 2 building_depth = max(max_x - min_x, max_y - min_y) * 2 if view_type == "front": camera.location = (center_x, center_y - building_depth, center_z) camera.rotation_euler = (1.5708, 0, 0) # 90 degrees X rotation elif view_type == "right": camera.location = (center_x + building_depth, center_y, center_z) camera.rotation_euler = (1.5708, 0, 1.5708) # Look from right elif view_type == "left": camera.location = (center_x - building_depth, center_y, center_z) camera.rotation_euler = (1.5708, 0, -1.5708) # Look from left # Adjust scale for elevations building_height = max_z - min_z building_width = max(max_x - min_x, max_y - min_y) camera.data.ortho_scale = max(building_height, building_width) * 1.2 elif view_type == "isometric": # For isometric view, use perspective camera positioned diagonally camera.data.type = 'PERSP' camera.data.lens = 35 # 35mm lens for nice perspective all_objects = [obj for obj in bpy.context.scene.objects if obj.type == 'MESH' and obj.visible_get()] if all_objects: # Calculate bounds min_x = min_y = min_z = float('inf') max_x = max_y = max_z = float('-inf') for obj in all_objects: bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box] for corner in bbox: min_x = min(min_x, corner.x) max_x = max(max_x, corner.x) min_y = min(min_y, corner.y) max_y = max(max_y, corner.y) min_z = min(min_z, corner.z) max_z = max(max_z, corner.z) center_x = (min_x + max_x) / 2 center_y = (min_y + max_y) / 2 center_z = (min_z + max_z) / 2 # Calculate distance to frame the building nicely building_size = max(max_x - min_x, max_y - min_y, max_z - min_z) distance = building_size * 1.2 # Distance multiplier for good framing # Position camera for isometric view (45° angles) # Classic isometric position: up and back, looking down at 30° import math angle_rad = math.radians(45) camera_x = center_x + distance * math.cos(angle_rad) camera_y = center_y - distance * math.sin(angle_rad) camera_z = center_z + distance * 0.3 # Lower elevation for better facade view camera.location = (camera_x, camera_y, camera_z) # Point camera at building center direction = mathutils.Vector((center_x - camera_x, center_y - camera_y, center_z - camera_z)) camera.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler() else: # Default isometric position camera.location = (15, -15, 10) camera.rotation_euler = (1.1, 0, 0.785) # ~63°, 0°, ~45° # Set up output file path if output_path: render_path = output_path else: temp_dir = tempfile.gettempdir() render_path = os.path.join(temp_dir, f"drawing_{view_type}_{int(time.time())}.png") scene.render.filepath = render_path scene.render.image_settings.file_format = 'PNG' # Render the image bpy.ops.render.render(write_still=True) # Read the rendered image and encode as base64 if os.path.exists(render_path): with open(render_path, 'rb') as f: image_data = f.read() # Clean up temporary file if we created it if not output_path: os.remove(render_path) # Restore original settings scene.render.engine = original_engine scene.render.resolution_x = original_res_x scene.render.resolution_y = original_res_y scene.render.filepath = original_filepath bpy.context.scene.camera = original_camera # Delete temporary camera bpy.data.objects.remove(camera, do_unlink=True) # Return base64 encoded image import base64 return { "success": True, "data": base64.b64encode(image_data).decode('utf-8'), "format": "png", "resolution": f"{resolution_x}x{resolution_y}", "view_type": view_type, "output_path": render_path if output_path else None } else: return {"error": "Failed to create render file"} except Exception as e: # Restore settings on error try: scene = bpy.context.scene scene.render.engine = original_engine scene.render.resolution_x = original_res_x scene.render.resolution_y = original_res_y scene.render.filepath = original_filepath bpy.context.scene.camera = original_camera # Clean up camera if it exists if 'camera' in locals() and camera: bpy.data.objects.remove(camera, do_unlink=True) except: pass import traceback return {"error": f"Error creating drawing: {str(e)}", "traceback": traceback.format_exc()} @staticmethod def get_ifc_georeferencing_info(include_contexts: bool = False): """ Retrieves georeferencing information from the currently opened IFC file (CRS, MapConversion, WCS, TrueNorth, IfcSite). Args: include_contexts (bool): If True, adds the breakdown of RepresentationContexts and operations Returns: dict: Structure with: { "georeferenced": bool, "crs": { "name": str|None, "geodetic_datum": str|None, "vertical_datum": str|None, "map_unit": str|None }, "map_conversion": { "eastings": float|None, "northings": float|None, "orthogonal_height": float|None, "scale": float|None, "x_axis_abscissa": float|None, "x_axis_ordinate": float|None }, "world_coordinate_system": {"origin": [x,y,z]|None}, "true_north": {"direction_ratios": [x,y]|None}, "site": { "local_placement_origin": [x,y,z]|None, "ref_latitude": [deg,min,sec,millionth]|None, "ref_longitude": [deg,min,sec,millionth]|None, "ref_elevation": float|None }, "contexts": [...], # only if include_contexts=True "warnings": [...] } """ try: file = IfcStore.get_file() debug = {"entered": True, "has_ifc": file is not None, "projects": 0, "sites": 0, "contexts": 0} if file is None: return {"error": "No IFC file is currently loaded", "debug": debug} warnings = [] result = { "georeferenced": False, "crs": { "name": None, "geodetic_datum": None, "vertical_datum": None, "map_unit": None }, "map_conversion": { "eastings": None, "northings": None, "orthogonal_height": None, "scale": None, "x_axis_abscissa": None, "x_axis_ordinate": None }, "world_coordinate_system": {"origin": None}, "true_north": {"direction_ratios": None}, "site": { "local_placement_origin": None, "ref_latitude": None, "ref_longitude": None, "ref_elevation": None }, "contexts": [], "warnings": warnings, "debug":debug, } # --- IfcProject & RepresentationContexts --- projects = file.by_type("IfcProject") debug["projects"] = len(projects) if projects: project = projects[0] contexts = getattr(project, "RepresentationContexts", None) or [] debug["contexts"] = len(contexts) for ctx in contexts: ctx_entry = { "context_identifier": getattr(ctx, "ContextIdentifier", None), "context_type": getattr(ctx, "ContextType", None), "world_origin": None, "true_north": None, "has_coordinate_operation": [] } # WorldCoordinateSystem → Local origin try: wcs = getattr(ctx, "WorldCoordinateSystem", None) if wcs and getattr(wcs, "Location", None): loc = wcs.Location if getattr(loc, "Coordinates", None): coords = list(loc.Coordinates) result["world_coordinate_system"]["origin"] = coords ctx_entry["world_origin"] = coords except Exception as e: warnings.append(f"WorldCoordinateSystem read error: {str(e)}") # TrueNorth try: if hasattr(ctx, "TrueNorth") and ctx.TrueNorth: tn = ctx.TrueNorth ratios = list(getattr(tn, "DirectionRatios", []) or []) result["true_north"]["direction_ratios"] = ratios ctx_entry["true_north"] = ratios except Exception as e: warnings.append(f"TrueNorth read error: {str(e)}") # HasCoordinateOperation → IfcMapConversion / TargetCRS try: if hasattr(ctx, "HasCoordinateOperation") and ctx.HasCoordinateOperation: for op in ctx.HasCoordinateOperation: op_entry = {"type": op.is_a(), "target_crs": None, "map_conversion": None} # TargetCRS crs = getattr(op, "TargetCRS", None) if crs: result["crs"]["name"] = getattr(crs, "Name", None) result["crs"]["geodetic_datum"] = getattr(crs, "GeodeticDatum", None) result["crs"]["vertical_datum"] = getattr(crs, "VerticalDatum", None) try: map_unit = getattr(crs, "MapUnit", None) result["crs"]["map_unit"] = map_unit.Name if map_unit else None except Exception: result["crs"]["map_unit"] = None op_entry["target_crs"] = { "name": result["crs"]["name"], "geodetic_datum": result["crs"]["geodetic_datum"], "vertical_datum": result["crs"]["vertical_datum"], "map_unit": result["crs"]["map_unit"] } # IfcMapConversion if op.is_a("IfcMapConversion"): mc = { "eastings": getattr(op, "Eastings", None), "northings": getattr(op, "Northings", None), "orthogonal_height": getattr(op, "OrthogonalHeight", None), "scale": getattr(op, "Scale", None), "x_axis_abscissa": getattr(op, "XAxisAbscissa", None), "x_axis_ordinate": getattr(op, "XAxisOrdinate", None) } result["map_conversion"].update(mc) op_entry["map_conversion"] = mc ctx_entry["has_coordinate_operation"].append(op_entry) except Exception as e: warnings.append(f"HasCoordinateOperation read error: {str(e)}") if include_contexts: result["contexts"].append(ctx_entry) else: warnings.append("IfcProject entity was not found.") # --- IfcSite (lat/long/alt local origin of placement) --- try: sites = file.by_type("IfcSite") debug["sites"] = len(sites) if sites: site = sites[0] # LocalPlacement try: if getattr(site, "ObjectPlacement", None): placement = site.ObjectPlacement axisPlacement = getattr(placement, "RelativePlacement", None) if axisPlacement and getattr(axisPlacement, "Location", None): loc = axisPlacement.Location if getattr(loc, "Coordinates", None): result["site"]["local_placement_origin"] = list(loc.Coordinates) except Exception as e: warnings.append(f"IfcSite.ObjectPlacement read error: {str(e)}") # Lat/Long/Alt try: lat = getattr(site, "RefLatitude", None) lon = getattr(site, "RefLongitude", None) ele = getattr(site, "RefElevation", None) result["site"]["ref_latitude"] = list(lat) if lat else None result["site"]["ref_longitude"] = list(lon) if lon else None result["site"]["ref_elevation"] = ele except Exception as e: warnings.append(f"IfcSite (lat/long/elev) read error: {str(e)}") else: warnings.append("IfcSite was not found.") except Exception as e: warnings.append(f"Error while querying IfcSite: {str(e)}") # --- Heuristic to determine georeferencing --- geo_flags = [ any(result["crs"].values()), any(v is not None for v in result["map_conversion"].values()) ] result["georeferenced"] = all(geo_flags) return result except Exception as e: import traceback return {"error": str(e), "traceback": traceback.format_exc()} @staticmethod def georeference_ifc_model( crs_mode: str, epsg: int = None, crs_name: str = None, geodetic_datum: str = None, map_projection: str = None, map_zone: str = None, eastings: float = None, northings: float = None, orthogonal_height: float = 0.0, scale: float = 1.0, x_axis_abscissa: float = None, x_axis_ordinate: float = None, true_north_azimuth_deg: float = None, context_filter: str = "Model", context_index: int = None, site_ref_latitude: list = None, # IFC format [deg, min, sec, millionth] site_ref_longitude: list = None, # IFC format [deg, min, sec, millionth] site_ref_elevation: float = None, site_ref_latitude_dd: float = None, # Decimal degrees (optional) site_ref_longitude_dd: float = None, # Decimal degrees (optional) overwrite: bool = False, dry_run: bool = False, write_path: str = None, ): """ Usage: Creates/updates IfcProjectedCRS + IfcMapConversion in the opened IFC. Optionally updates IfcSite.RefLatitude/RefLongitude/RefElevation. If `pyproj` is available, it can convert Lat/Long (degrees) ⇄ E/N (meters) according to the given EPSG. Requirements: CRS declaration is ALWAYS required: - crs_mode="epsg" + epsg=XXXX OR - crs_mode="custom" + (crs_name, geodetic_datum, map_projection [, map_zone]) Minimum MapConversion information: - eastings + northings (if missing but lat/long + EPSG + pyproj are available, they are computed) """ import math from bonsai.bim.ifc import IfcStore file = IfcStore.get_file() if file is None: return {"success": False, "error": "No IFC file is currently loaded"} warnings = [] actions = {"created_crs": False, "created_map_conversion": False, "updated_map_conversion": False, "updated_site": False, "overwrote": False, "wrote_file": False} debug = {} # ---------- helpers ---------- def dd_to_ifc_dms(dd: float): """Converts decimal degrees to [deg, min, sec, millionth] (sign carried by degrees).""" if dd is None: return None sign = -1 if dd < 0 else 1 v = abs(dd) deg = int(v) rem = (v - deg) * 60 minutes = int(rem) sec_float = (rem - minutes) * 60 seconds = int(sec_float) millionth = int(round((sec_float - seconds) * 1_000_000)) # Normalizes rounding (e.g. 59.999999 → 60) if millionth == 1_000_000: seconds += 1 millionth = 0 if seconds == 60: minutes += 1 seconds = 0 if minutes == 60: deg += 1 minutes = 0 return [sign * deg, minutes, seconds, millionth] def select_context(): ctxs = file.by_type("IfcGeometricRepresentationContext") or [] if not ctxs: return None, "No IfcGeometricRepresentationContext found" if context_index is not None and 0 <= context_index < len(ctxs): return ctxs[context_index], None # By filter (default "Model", case-insensitive) if context_filter: for c in ctxs: if (getattr(c, "ContextType", None) or "").lower() == context_filter.lower(): return c, None # Fallback to the first one return ctxs[0], None # ---------- 1) CRS Validation ---------- if crs_mode not in ("epsg", "custom"): return {"success": False, "error": "crs_mode must be 'epsg' or 'custom'"} if crs_mode == "epsg": if not epsg: return {"success": False, "error": "epsg code required when crs_mode='epsg'"} crs_name_final = f"EPSG:{epsg}" geodetic_datum = geodetic_datum or "WGS84" map_projection = map_projection or "TransverseMercator" # usual UTM # map_zone is optional else: # custom missing = [k for k in ("crs_name", "geodetic_datum", "map_projection") if locals().get(k) in (None, "")] if missing: return {"success": False, "error": f"Missing fields for custom CRS: {', '.join(missing)}"} crs_name_final = crs_name # ---------- 2) Complete E/N from Lat/Long (if missing and pyproj is available) ---------- proj_used = None try: if (eastings is None or northings is None) and (site_ref_latitude_dd is not None and site_ref_longitude_dd is not None) and crs_mode == "epsg": try: from pyproj import Transformer # Assume lat/long in WGS84; if the EPSG is not WGS84-derived, pyproj handles the conversion transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) e, n = transformer.transform(site_ref_longitude_dd, site_ref_latitude_dd) eastings = e if eastings is None else eastings northings = n if northings is None else northings proj_used = f"EPSG:4326->EPSG:{epsg}" except Exception as _e: warnings.append(f"Could not convert Lat/Long to E/N: {_e}. Provide eastings/northings manually.") except Exception as _e: warnings.append(f"pyproj not available to compute E/N: {_e}. Provide eastings/northings manually.") # ---------- E/N Validation ---------- if eastings is None or northings is None: return {"success": False, "error": "eastings and northings are required (or provide lat/long + EPSG with pyproj installed)"} # ---------- 3) Select context ---------- context, ctx_err = select_context() if not context: return {"success": False, "error": ctx_err or "No context found"} # ---------- 4) Detect existing ones and handle overwrite ---------- # Inverse: context.HasCoordinateOperation is already handled by ifcopenshell as an attribute existing_ops = list(getattr(context, "HasCoordinateOperation", []) or []) existing_map = None existing_crs = None for op in existing_ops: if op.is_a("IfcMapConversion"): existing_map = op existing_crs = getattr(op, "TargetCRS", None) break if existing_map and not overwrite: return { "success": True, "georeferenced": True, "message": "MapConversion already exists. Use overwrite=True to replace it.", "context_used": {"identifier": getattr(context, "ContextIdentifier", None), "type": getattr(context, "ContextType", None)}, "map_conversion": { "eastings": getattr(existing_map, "Eastings", None), "northings": getattr(existing_map, "Northings", None), "orthogonal_height": getattr(existing_map, "OrthogonalHeight", None), "scale": getattr(existing_map, "Scale", None), "x_axis_abscissa": getattr(existing_map, "XAxisAbscissa", None), "x_axis_ordinate": getattr(existing_map, "XAxisOrdinate", None), }, "crs": { "name": getattr(existing_crs, "Name", None) if existing_crs else None, "geodetic_datum": getattr(existing_crs, "GeodeticDatum", None) if existing_crs else None, "map_projection": getattr(existing_crs, "MapProjection", None) if existing_crs else None, "map_zone": getattr(existing_crs, "MapZone", None) if existing_crs else None, }, "warnings": warnings, "actions": actions, } # ---------- 5) Build/Update CRS ---------- if existing_crs and overwrite: actions["overwrote"] = True try: file.remove(existing_crs) except Exception: warnings.append("Could not remove the existing CRS; a new one will be created anyway.") # If custom, use the provided values; if EPSG, build the name and defaults crs_kwargs = { "Name": crs_name_final, "GeodeticDatum": geodetic_datum, "MapProjection": map_projection, } if map_zone: crs_kwargs["MapZone"] = map_zone crs_entity = file.create_entity("IfcProjectedCRS", **crs_kwargs) actions["created_crs"] = True # ---------- 6) Calculate orientation (optional) ---------- # If true_north_azimuth_deg is given as the azimuth from North (model +Y axis) towards East (clockwise), # We can derive an approximate X vector: X = (cos(az+90°), sin(az+90°)). if (x_axis_abscissa is None or x_axis_ordinate is None) and (true_north_azimuth_deg is not None): az = math.radians(true_north_azimuth_deg) # Estimated X vector rotated 90° from North: x_axis_abscissa = math.cos(az + math.pi / 2.0) x_axis_ordinate = math.sin(az + math.pi / 2.0) # Defaults if still missing x_axis_abscissa = 1.0 if x_axis_abscissa is None else float(x_axis_abscissa) x_axis_ordinate = 0.0 if x_axis_ordinate is None else float(x_axis_ordinate) scale = 1.0 if scale is None else float(scale) orthogonal_height = 0.0 if orthogonal_height is None else float(orthogonal_height) # ---------- 7) Build/Update IfcMapConversion ---------- if existing_map and overwrite: try: file.remove(existing_map) except Exception: warnings.append("Could not remove the existing MapConversion; another one will be created anyway.") map_kwargs = { "SourceCRS": context, "TargetCRS": crs_entity, "Eastings": float(eastings), "Northings": float(northings), "OrthogonalHeight": float(orthogonal_height), "XAxisAbscissa": float(x_axis_abscissa), "XAxisOrdinate": float(x_axis_ordinate), "Scale": float(scale), } map_entity = file.create_entity("IfcMapConversion", **map_kwargs) actions["created_map_conversion"] = True # ---------- 8) (Optional) Update IfcSite ---------- try: sites = file.by_type("IfcSite") or [] if sites: site = sites[0] # If no IFC lists are provided but decimal degrees are, convert them if site_ref_latitude is None and site_ref_latitude_dd is not None: site_ref_latitude = dd_to_ifc_dms(site_ref_latitude_dd) if site_ref_longitude is None and site_ref_longitude_dd is not None: site_ref_longitude = dd_to_ifc_dms(site_ref_longitude_dd) changed = False if site_ref_latitude is not None: site.RefLatitude = site_ref_latitude changed = True if site_ref_longitude is not None: site.RefLongitude = site_ref_longitude changed = True if site_ref_elevation is not None: site.RefElevation = float(site_ref_elevation) changed = True if changed: actions["updated_site"] = True else: warnings.append("No IfcSite found; lat/long/elevation were not updated.") except Exception as e: warnings.append(f"Could not update IfcSite: {e}") # ---------- 9) (Optional) Save ---------- if write_path and not dry_run: try: file.write(write_path) actions["wrote_file"] = True except Exception as e: warnings.append(f"Could not write IFC to'{write_path}': {e}") # ---------- 10) Response ---------- return { "success": True, "georeferenced": True, "crs": { "name": getattr(crs_entity, "Name", None), "geodetic_datum": getattr(crs_entity, "GeodeticDatum", None), "map_projection": getattr(crs_entity, "MapProjection", None), "map_zone": getattr(crs_entity, "MapZone", None), }, "map_conversion": { "eastings": float(eastings), "northings": float(northings), "orthogonal_height": float(orthogonal_height), "scale": float(scale), "x_axis_abscissa": float(x_axis_abscissa), "x_axis_ordinate": float(x_axis_ordinate), }, "context_used": { "identifier": getattr(context, "ContextIdentifier", None), "type": getattr(context, "ContextType", None), }, "site": { "ref_latitude": site_ref_latitude, "ref_longitude": site_ref_longitude, "ref_elevation": site_ref_elevation, }, "proj_used": proj_used, "warnings": warnings, "actions": actions, } #endregion def extract_quantities(entity, blender_name=None): """ Extract quantity information from an IFC entity. Parameters: entity: IFC entity object blender_name: Optional Blender object name Returns: Dictionary with element info and quantities """ try: # Get all property sets psets = ifcopenshell.util.element.get_psets(entity) # Basic element info element_data = { "id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}", "name": entity.Name if hasattr(entity, "Name") else None, "type": entity.is_a(), "blender_name": blender_name, "quantities": {} } # Look for quantity information in different property sets quantity_sources = ["BaseQuantities", "ArchiCADQuantities", "Qto_WallBaseQuantities", "Qto_SlabBaseQuantities", "Qto_BeamBaseQuantities", "Qto_ColumnBaseQuantities"] # Common quantity mappings quantity_mappings = { # Area measurements "GrossSideArea": "area", "NetSideArea": "net_area", "GrossFootprintArea": "footprint_area", "NetFootprintArea": "net_footprint_area", "Oberflächenbereich": "surface_area", # Volume measurements "GrossVolume": "volume", "NetVolume": "net_volume", "Netto-Volumen": "net_volume_de", "Brutto-Volumen der Wand ": "gross_volume_de", # Length measurements "Length": "length", "Height": "height", "Width": "width", "Höhe": "height_de", "Dicke": "thickness", # Other measurements "Fläche": "area_de", "Wandlänge an der Außenseite": "outer_length", "Wandlänge an der Innenseite": "inner_length" } # Extract quantities from property sets for pset_name in quantity_sources: if pset_name in psets: pset_data = psets[pset_name] for prop_name, prop_value in pset_data.items(): if prop_name in quantity_mappings and isinstance(prop_value, (int, float)): mapped_name = quantity_mappings[prop_name] element_data["quantities"][mapped_name] = prop_value # Add common derived quantities if available if "area" in element_data["quantities"]: element_data["quantities"]["area_m2"] = element_data["quantities"]["area"] if "volume" in element_data["quantities"]: element_data["quantities"]["volume_m3"] = element_data["quantities"]["volume"] return element_data if element_data["quantities"] else None except Exception as e: return None # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Bonsai MCP" bl_idname = "BLENDERMCP_PT_Panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = 'Bonsai MCP' def draw(self, context): layout = self.layout scene = context.scene layout.prop(scene, "blendermcp_port") if not scene.blendermcp_server_running: layout.operator("blendermcp.start_server", text="Start MCP Server") else: layout.operator("blendermcp.stop_server", text="Stop MCP Server") layout.label(text=f"Running on port {scene.blendermcp_port}") # Operator to start the server class BLENDERMCP_OT_StartServer(bpy.types.Operator): bl_idname = "blendermcp.start_server" bl_label = "Connect to Claude" bl_description = "Start the BlenderMCP server to connect with Claude" def execute(self, context): scene = context.scene # Create a new server instance if not hasattr(bpy.types, "blendermcp_server") or not bpy.types.blendermcp_server: bpy.types.blendermcp_server = BlenderMCPServer(port=scene.blendermcp_port) # Start the server bpy.types.blendermcp_server.start() scene.blendermcp_server_running = True return {'FINISHED'} # Operator to stop the server class BLENDERMCP_OT_StopServer(bpy.types.Operator): bl_idname = "blendermcp.stop_server" bl_label = "Stop the connection to Claude" bl_description = "Stop the connection to Claude" def execute(self, context): scene = context.scene # Stop the server if it exists if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server: bpy.types.blendermcp_server.stop() del bpy.types.blendermcp_server scene.blendermcp_server_running = False return {'FINISHED'} # Registration functions def register(): bpy.types.Scene.blendermcp_port = IntProperty( name="Port", description="Port for the BlenderMCP server", default=9876, min=1024, max=65535 ) bpy.types.Scene.blendermcp_server_running = bpy.props.BoolProperty( name="Server Running", default=False ) bpy.utils.register_class(BLENDERMCP_PT_Panel) bpy.utils.register_class(BLENDERMCP_OT_StartServer) bpy.utils.register_class(BLENDERMCP_OT_StopServer) print("BlenderMCP addon registered") def unregister(): # Stop the server if it's running if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server: bpy.types.blendermcp_server.stop() del bpy.types.blendermcp_server bpy.utils.unregister_class(BLENDERMCP_PT_Panel) bpy.utils.unregister_class(BLENDERMCP_OT_StartServer) bpy.utils.unregister_class(BLENDERMCP_OT_StopServer) del bpy.types.Scene.blendermcp_port del bpy.types.Scene.blendermcp_server_running print("BlenderMCP addon unregistered") if __name__ == "__main__": register()

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/JotaDeRodriguez/Bonsai_mcp'

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