This is page 1 of 2. Use http://codebase.md/jotaderodriguez/bonsai_mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── addon.py ├── bc3_writer.py ├── dockerfile ├── LICENSE.md ├── pyproject.toml ├── README.md ├── resources │ ├── bc3_helper_files │ │ ├── element_categories.json │ │ ├── precios_unitarios.json │ │ ├── spatial_labels_en.json │ │ ├── spatial_labels_es.json │ │ └── unit_prices.json │ └── table_of_contents.json ├── tools.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | *.md 2 | __pycache__ 3 | exports ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | 2 | # Bonsai-mcp - Model Context Protocol Integration for IFC through IfcOpenShell and Blender 3 | 4 | Bonsai-mcp is a fork of [BlenderMCP](https://github.com/ahujasid/blender-mcp) that extends the original functionality with dedicated support for IFC (Industry Foundation Classes) models through Bonsai. This integration is a platform to let LLMs read and modify IFC files. 5 | 6 | ## Features 7 | 8 | - **IFC-specific functionality**: Query IFC models, analyze spatial structures, examine building elements and extract quantities 9 | 10 | - **Eleven IFC tools included**: Inspect project info, list entities, examine properties, explore spatial structure, analyze relationships and more 11 | 12 | - **Sequential Thinking**: Includes the sequential thinking tool from [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) for structured problem solving 13 | 14 | - **Execute Code tool from the original BlenderMCP implementation**: Create and modify objects, apply materials, and execute Python code in Blender 15 | 16 | ## Components 17 | 18 | The system consists of two main components: 19 | 20 | 1. **Blender Addon (`addon.py`)**: A Blender addon that creates a socket server within Blender to receive and execute commands, including IFC-specific operations 21 | 22 | 2. **MCP Server (`tools.py`)**: A Python server that implements the Model Context Protocol and connects to the Blender addon 23 | 24 | ## Installation - Through MCP Client Settings 25 | 26 | ### Prerequisites 27 | 28 | - Blender 4.0 or newer 29 | 30 | - Python 3.12 or newer 31 | 32 | - uv package manager 33 | 34 | - Bonsai BIM addon for Blender (for IFC functionality) 35 | 36 | **Installing uv:** 37 | 38 | **Mac:** 39 | 40 | ```bash 41 | 42 | brew install uv 43 | 44 | ``` 45 | 46 | **Windows:** 47 | 48 | ```bash 49 | 50 | powershell -c "irm https://astral.sh/uv/install.ps1 | iex" 51 | 52 | set Path=C:\Users\[username]\.local\bin;%Path% 53 | 54 | ``` 55 | 56 | For other platforms, see the [uv installation guide](https://docs.astral.sh/uv/getting-started/installation/). 57 | 58 | ### Clone the repository 59 | 60 | ```bash 61 | 62 | git clone https://github.com/JotaDeRodriguez/Bonsai_mcp 63 | 64 | ``` 65 | 66 | ### Claude for Desktop Integration 67 | 68 | Edit your `claude_desktop_config.json` file (Claude > Settings > Developer > Edit Config) to include: 69 | 70 | ```json 71 | { 72 | "mcpServers": { 73 | "Bonsai-mcp": { 74 | "command": "uv", 75 | "args": [ 76 | "--directory", 77 | "\\your\\path\\to\\Bonsai_mcp", 78 | "run", 79 | "tools.py" 80 | ] 81 | } 82 | } 83 | } 84 | 85 | ``` 86 | 87 | ## Installation via Docker 88 | 89 | The repository comes with a Dockerfile that makes deployment simple and consistent across different environments. 90 | 91 | ## Quick Start 92 | 93 | ```bash 94 | # Clone the repository 95 | git clone https://github.com/JotaDeRodriguez/Bonsai_mcp 96 | cd Bonsai_mcp 97 | 98 | # Build the Docker image 99 | docker build -t bonsai_mcp . 100 | 101 | # Run the container 102 | docker run -p 8000:8000 --name bonsai_mcp bonsai_mcp 103 | ``` 104 | 105 | Once running, the container will expose the MCP tools as REST/OpenAPI APIs at `http://localhost:8000`. 106 | 107 | - To verify youtr installation, open your browser and navigate to 108 | - `http://localhost:8000/docs` 109 | - You'll see the Swagger UI with all available endpoints 110 | - Test an endpoint by clicking on it, then click "Try it out" and "Execute" 111 | 112 | ### Connecting to Open WebUI or Other API Clients 113 | 114 | To connect this API to Open WebUI: 115 | 116 | 1. In Open WebUI, go to Settings > Manage Tool Servers 117 | 2. Add a new connection with: 118 | 119 | - URL: `http://localhost:8000` 120 | - Path to OpenAPI spec: `/openapi.json` 121 | - Authentication: None (unless configured otherwise) 122 | 123 | ### Environment Variables 124 | 125 | The Docker container accepts several environment variables to customize its behavior: 126 | 127 | ```bash 128 | # Example with custom settings 129 | docker run -p 8000:8000 \ 130 | -e BLENDER_HOST=host.docker.internal \ 131 | -e BLENDER_PORT=9876 \ 132 | -e MCP_HOST=0.0.0.0 \ 133 | -e MCP_PORT=8000 \ 134 | --name bonsai_mcp bonsai_mcp 135 | ``` 136 | 137 | ## Installing the Blender Addon 138 | 139 | 1. Download the `addon.py` file from this repo 140 | 141 | 2. Open Blender 142 | 143 | 3. Go to Edit > Preferences > Add-ons 144 | 145 | 4. Click "Install..." and select the `addon.py` file 146 | 147 | 5. Enable the addon by checking the box next to "Interface: Blender MCP - IFC" 148 | 149 | ## Usage 150 | 151 | ### Starting the Connection 152 | 153 | 1. In Blender, go to the 3D View sidebar (press N if not visible) 154 | 155 | 2. Find the "Blender MCP - IFC" tab 156 | 157 | 3. Click "Connect to Claude" 158 | 159 | 4. Make sure the MCP server is running 160 | 161 | ### Using with Claude 162 | 163 | Once connected, you'll see a hammer icon in Claude's interface with tools for the Blender MCP IFC integration. 164 | 165 | ## IFC Tools 166 | 167 | This repo includes multiple IFC-specific tools that enable comprehensive querying and manipulation of IFC models: 168 | 169 | **get_ifc_project_info**: Retrieves basic information about the IFC project, including name, description, and counts of different entity types. Example: "What is the basic information about this IFC project?" 170 | 171 | **list_ifc_entities**: Lists IFC entities of a specific type (walls, doors, spaces, etc.) with options to limit results and filter by selection. Example: "List all the walls in this IFC model" or "Show me the windows in this building" 172 | 173 | **get_ifc_properties**: Retrieves all properties of a specific IFC entity by its GlobalId or from currently selected objects. Example: "What are the properties of this wall with ID 1Dvrgv7Tf5IfTEapMkwDQY?" 174 | 175 | **get_ifc_spatial_structure**: Gets the spatial hierarchy of the IFC model (site, building, storeys, spaces). Example: "Show me the spatial structure of this building" 176 | 177 | **get_ifc_relationships**: Retrieves all relationships for a specific IFC entity. Example: "What are the relationships of the entrance door?" 178 | 179 | **get_selected_ifc_entities**: Gets information about IFC entities corresponding to objects currently selected in the Blender UI. Example: "Tell me about the elements I've selected in Blender" 180 | 181 | **get_user_view**: Captures the current Blender viewport as an image, allowing visualization of the model from the user's perspective. Example: "Show me what the user is currently seeing in Blender" 182 | 183 | **export_ifc_data**: Exports IFC data to a structured JSON or CSV file, with options to filter by entity type or building level. Example: "Export all wall data to a CSV file" 184 | 185 | **place_ifc_object**: Creates and positions an IFC element in the model at specified coordinates with optional rotation. Example: "Place a door at coordinates X:10, Y:5, Z:0 with 90 degrees rotation" 186 | 187 | **get_ifc_quantities**: Calculate and get quantities (m2, m3, etc.) for IFC elements, with options to filter by entity type or selected ones. Example: "Give me the area of all the walls in the building using the tool get_ifc_quantities" 188 | 189 | **get_ifc_total_structure**: Retrieves the complete hierarchical structure of the IFC model including spatial elements (Project, Site, Building, Storeys) and all building elements within each spatial container. This comprehensive view combines spatial hierarchy with building elements, essential for generating complete reports and budgets. Example: "Show me the complete structure of this IFC model including all building elements organized by floor" 190 | 191 | **export_drawing_png**: Exports 2D and 3D drawings as high-resolution PNG images with customizable resolution and view parameters. Creates orthographic plan views from above at specified height offsets. Example: "Generate a floor plan PNG for the ground floor at 1920x1080 resolution" 192 | 193 | **get_ifc_georeferencing_info**: Retrieves comprehensive georeferencing information from IFC files including coordinate reference systems (CRS), map conversions, world coordinate systems, true north direction, and site geographic coordinates. Example: "What georeferencing information is available in this IFC model?" 194 | 195 | **georeference_ifc_model**: Creates or updates georeferencing information in IFC models, allowing you to set coordinate reference systems using EPSG codes or custom CRS definitions, establish map conversions with eastings/northings coordinates, and configure site geographic positioning. Example: "Georeference this IFC model using EPSG:4326 with coordinates at latitude 40.7589, longitude -73.9851" 196 | 197 | **export_bc3_budget**: Exports a BC3 budget file (FIEBDC-3/2016 format) based on the IFC model loaded in Blender. This tool creates a complete construction budget by extracting the IFC spatial structure, grouping building elements by type and category (structure, masonry, slabs, carpentry, installations, furniture), assigning unit prices from a comprehensive database, and generating detailed measurements. Supports multi-language output (Spanish/English) with proper encoding for international characters. The BC3 format is the Spanish standard for construction budgets and cost estimation. Example: "Generate a BC3 budget file in Spanish for this building model" 198 | 199 | ### Features 200 | 201 | - **Automatic element categorization**: Building elements are automatically classified into categories: 202 | - **ESTR**: Structural elements (beams, columns, footings, piles, ramps, stairs) 203 | - **ALB**: Masonry (walls) 204 | - **FORG**: Slabs and roofs 205 | - **CARP**: Carpentry (doors, windows) 206 | - **INST**: Installations (pipes, fittings, terminals, railings) 207 | - **MOB**: Furniture 208 | 209 | - **Accurate measurements**: 210 | - Walls measured by NetSideArea (accounts for openings like doors and windows) 211 | - Slabs and roofs measured by GrossVolume 212 | - Beams, columns, and piles measured by length (meters) 213 | - Doors, windows, and furniture counted as units 214 | 215 | - **Multi-language support**: Generate budgets in Spanish or English with proper character encoding (windows-1252) 216 | 217 | - **Hierarchical structure**: Budget chapters follow the IFC spatial hierarchy (Project → Site → Building → Storey) 218 | 219 | - **Unit price database**: Includes comprehensive unit prices for common construction elements, fully customizable via JSON files 220 | 221 | - **Sorted measurements**: Elements within each category are sorted alphabetically for easier review 222 | 223 | ### Configuration Files 224 | 225 | The BC3 export uses external JSON configuration files located in `resources/bc3_helper_files/`: 226 | 227 | - `precios_unitarios.json` / `unit_prices.json`: Unit prices per IFC element type 228 | - `spatial_labels_es.json` / `spatial_labels_en.json`: Spatial element translations 229 | - `element_categories.json`: IFC type to budget category mappings 230 | 231 | These files can be customized to adapt the budget generation to specific project needs or regional pricing standards. 232 | 233 | ### Output 234 | 235 | BC3 files are exported to the `exports/` folder with proper FIEBDC-3/2016 format, including: 236 | - Complete hierarchical chapter structure 237 | - Detailed measurements for each element 238 | - Unit prices and totals 239 | - Full compliance with Spanish construction budget standard bc3 240 | 241 | ## MCP Resources 242 | 243 | This integration provides access to structured documentation through MCP resources: 244 | 245 | **file://table_of_contents.md**: Contains the complete technical report structure template for generating comprehensive building reports. This resource provides a standardized table of contents that can be used as a reference when creating technical documentation from IFC models. 246 | 247 | ## MCP Prompts 248 | 249 | The server includes specialized MCP Prompts for automated report generation: 250 | 251 | **Technical_building_report**: Generates comprehensive technical building reports based on IFC models loaded in Blender. This prompt provides a structured workflow for creating professional architectural documentation in multiple languages (English, Spanish, French, German, Italian, Portuguese). The prompt guides the analysis through systematic data extraction from the IFC model, including spatial structure, quantities, materials, and building systems, culminating in a complete technical report with drawings and 3D visualizations. 252 | 253 | ## Execute Blender Code 254 | 255 | Legacy feature from the original MCP implementation. Allows Claude to execute arbitrary Python code in Blender. Use with caution. 256 | 257 | ## Sequential Thinking Tool 258 | 259 | This integration includes the Sequential Thinking tool for structured problem-solving and analysis. It facilitates a step-by-step thinking process that can branch, revise, and adapt as understanding deepens - perfect for complex IFC model analysis or planning tasks. 260 | 261 | Example: "Use sequential thinking to analyze this building's energy efficiency based on the IFC model" 262 | 263 | ## Example Commands 264 | 265 | Here are some examples of what you can ask Claude to do with IFC models: 266 | 267 | - "Analyze this IFC model and tell me how many walls, doors and windows it has" 268 | 269 | - "Show me the spatial structure of this building model" 270 | 271 | - "List all spaces in this IFC model and their properties" 272 | 273 | - "Identify all structural elements in this building" 274 | 275 | - "What are the relationships between this wall and other elements?" 276 | 277 | - "Generate a report of the measurements from the IFC model opened in Blender" 278 | 279 | - "Use sequential thinking to create a maintenance plan for this building based on the IFC model" 280 | 281 | - "Generate a BC3 budget file in Spanish for the current IFC model" 282 | 283 | - "Export a construction cost estimate to BC3 format with English descriptions" 284 | 285 | ## Troubleshooting 286 | 287 | - **Connection issues**: Make sure the Blender addon server is running, and the MCP server is configured in Claude 288 | - **IFC model not loading**: Verify that you have the Bonsai BIM addon installed and that an IFC file is loaded 289 | - **Timeout errors**: Try simplifying your requests or breaking them into smaller steps 290 | 291 | **Docker:** 292 | 293 | - **"Connection refused" errors**: Make sure Blender is running and the addon is enabled with the server started 294 | - **CORS issues**: The API has CORS enabled by default for all origins. If you encounter issues, check your client's CORS settings 295 | - **Performance concerns**: For large IFC models, the API responses might be slower. Consider adjusting timeouts in your client 296 | 297 | ## Technical Details 298 | 299 | The IFC integration uses the Bonsai BIM module to access ifcopenshell functionality within Blender. The communication follows the same JSON-based protocol over TCP sockets as the original BlenderMCP. 300 | 301 | ## Limitations & Security Considerations 302 | 303 | - The `execute_blender_code` tool from the original project is still available, allowing running arbitrary Python code in Blender. Use with caution and always save your work. 304 | 305 | - Complex IFC models may require breaking down operations into smaller steps. 306 | 307 | - IFC query performance depends on model size and complexity. 308 | 309 | - Get User View tool returns a base64 encoded image. Please ensure the client supports it. 310 | 311 | ## Contributions 312 | 313 | This MIT licensed repo is open to be forked, modified and used in any way. I'm open to ideas and collaborations, so don't hesitate to get in contact with me for contributions. 314 | 315 | ## Credits 316 | 317 | - Original BlenderMCP by [Siddharth Ahuja](https://github.com/ahujasid/blender-mcp) 318 | 319 | - Sequential Thinking tool from [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking) 320 | 321 | - IFC integration built upon the Bonsai BIM addon for Blender 322 | 323 | ## TO DO 324 | 325 | Integration and testing with more MCP Clients 326 | ``` -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- ```markdown 1 | MIT License 2 | 3 | Copyright (c) 2025 Juan Rodriguez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | ``` -------------------------------------------------------------------------------- /resources/bc3_helper_files/spatial_labels_es.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "spatial_labels": { 3 | "IfcProject": "Proyecto", 4 | "IfcSite": "Parcela", 5 | "IfcBuilding": "Edificio", 6 | "IfcBuildingStorey": "Planta", 7 | "IfcBridge": "Puente", 8 | "IfcBridgePart": "Subestructura" 9 | } 10 | } 11 | ``` -------------------------------------------------------------------------------- /resources/bc3_helper_files/spatial_labels_en.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "spatial_labels": { 3 | "IfcProject": "Project", 4 | "IfcSite": "Site", 5 | "IfcBuilding": "Building", 6 | "IfcBuildingStorey": "Building Storey", 7 | "IfcBridge": "Bridge", 8 | "IfcBridgePart": "Substructure" 9 | } 10 | } 11 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "Bonsai_mcp" 3 | version = "0.1.0" 4 | description = "A minimal MCP Server to illusrate the interaction between Bonsai BIM (Blender) and Claude." 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.4.1", 10 | "pillow>=11.3.0", 11 | ] 12 | authors = [ 13 | {name = "JotaDeRodriguez"} 14 | ] 15 | license = {text = "MIT"} 16 | [project.urls] 17 | "Homepage" = "https://github.com/JotaDeRodriguez/Bonsai_mcp" 18 | "Bug Tracker" = "https://github.com/JotaDeRodriguez/Bonsai_mcp/issues" 19 | ``` -------------------------------------------------------------------------------- /resources/bc3_helper_files/element_categories.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "element_categories": { 3 | "ESTR": [ 4 | "IfcBeam", 5 | "IfcColumn", 6 | "IfcFooting", 7 | "IfcPile", 8 | "IfcRamp", 9 | "IfcStair" 10 | ], 11 | "ALB": [ 12 | "IfcWall", 13 | "IfcWallStandardCase" 14 | ], 15 | "FORG": [ 16 | "IfcSlab", 17 | "IfcRoof", 18 | "IfcCovering" 19 | ], 20 | "CARP": [ 21 | "IfcDoor", 22 | "IfcWindow" 23 | ], 24 | "INST": [ 25 | "IfcFlowSegment", 26 | "IfcFlowFitting", 27 | "IfcFlowTerminal", 28 | "IfcDistributionElement", 29 | "IfcRailing" 30 | ], 31 | "MOB": [ 32 | "IfcFurnishingElement", 33 | "IfcFurniture" 34 | ] 35 | } 36 | } 37 | ``` -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM python:3.11-slim 2 | 3 | WORKDIR /app 4 | 5 | # Copy your application files 6 | COPY tools.py /app/ 7 | 8 | # Install dependencies 9 | RUN pip install mcpo uv 10 | 11 | # Set environment variables with defaults 12 | ENV MCP_HOST="0.0.0.0" 13 | ENV MCP_PORT=8000 14 | ENV BLENDER_HOST="host.docker.internal" 15 | ENV BLENDER_PORT=9876 16 | 17 | # Expose the port 18 | EXPOSE ${MCP_PORT} 19 | 20 | # Create a startup script that will modify the tools.py file before running 21 | RUN echo '#!/bin/bash\n\ 22 | # Replace localhost with the BLENDER_HOST environment variable in tools.py\n\ 23 | sed -i "s/host=\"localhost\"/host=\"$BLENDER_HOST\"/g" tools.py\n\ 24 | sed -i "s/host='\''localhost'\''/host='\''$BLENDER_HOST'\''/g" tools.py\n\ 25 | # Print the modification for debugging\n\ 26 | echo "Modified Blender host to: $BLENDER_HOST"\n\ 27 | # Run the MCPO server\n\ 28 | uvx mcpo --host $MCP_HOST --port $MCP_PORT -- python tools.py\n\ 29 | ' > /app/start.sh && chmod +x /app/start.sh 30 | 31 | # Run the startup script 32 | CMD ["/app/start.sh"] ``` -------------------------------------------------------------------------------- /resources/table_of_contents.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "title": "Technical Report – Basic Building Project", 3 | "children": { 4 | "1": { 5 | "title": "Introduction", 6 | "children": { 7 | "1.1": "Project objective", 8 | "1.2": "Documentation scope", 9 | "1.3": "Background and justification" 10 | } 11 | }, 12 | "2": { 13 | "title": "General Building Data", 14 | "children": { 15 | "2.1": "Location and site", 16 | "2.2": "Building intended use", 17 | "2.3": "Built and usable area (per floor)", 18 | "2.4": "Capacity (users, dwellings, premises, etc.)", 19 | "2.5": "Urban planning and technical regulations applicable" 20 | } 21 | }, 22 | "3": { 23 | "title": "Environmental Conditions", 24 | "children": { 25 | "3.1": "Site description", 26 | "3.2": "Urban environment and access", 27 | "3.3": "Topographic and climatic conditions" 28 | } 29 | }, 30 | "4": { 31 | "title": "Architectural Solution", 32 | "children": { 33 | "4.1": "General design criteria", 34 | "4.2": "Functional distribution and space organization", 35 | "4.3": "Facade and volumetric description", 36 | "4.4": "Accessibility and evacuation" 37 | } 38 | }, 39 | "5": { 40 | "title": "Planned Construction Systems", 41 | "children": { 42 | "5.1": "Structure (type and main material)", 43 | "5.2": "Foundation (adopted criteria)", 44 | "5.3": "External enclosures", 45 | "5.4": "Roofs and carpentry", 46 | "5.5": "Main interior finishes" 47 | } 48 | }, 49 | "6": { 50 | "title": "General Installations", 51 | "children": { 52 | "6.1": "Water supply and drainage", 53 | "6.2": "Electricity and telecommunications", 54 | "6.3": "Air conditioning and ventilation (if applicable)", 55 | "6.4": "Fire protection (basic criteria)", 56 | "6.5": "Renewable energy (if applicable)" 57 | } 58 | }, 59 | "7": { 60 | "title": "Safety and Health", 61 | "children": { 62 | "7.1": "Main risk identification", 63 | "7.2": "Safety criteria adopted" 64 | } 65 | }, 66 | "8": { 67 | "title": "Regulatory Compliance", 68 | "children": { 69 | "8.1": "Urban planning regulations", 70 | "8.2": "Habitability and accessibility regulations", 71 | "8.3": "Fire safety regulations", 72 | "8.4": "Energy efficiency regulations" 73 | } 74 | }, 75 | "9": { 76 | "title": "Areas and Area Schedule", 77 | "children": { 78 | "9.1": "Summary table of built and usable areas", 79 | "9.2": "Occupancy and buildability ratios" 80 | } 81 | }, 82 | "10": { 83 | "title": "Conclusions", 84 | "children": { 85 | "10.1": "Justification of the adopted solution", 86 | "10.2": "Adaptation to environment and regulations" 87 | } 88 | }, 89 | "11": { 90 | "title": "Annexes", 91 | "children": { 92 | "11.1": "Basic project plans", 93 | "11.2": "Complementary documentation (if applicable)" 94 | } 95 | } 96 | } 97 | } 98 | ``` -------------------------------------------------------------------------------- /resources/bc3_helper_files/unit_prices.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "prices": [ 3 | { 4 | "code": "ESTR001", 5 | "ifc_class": "IfcBeam", 6 | "description": "Beam structural element", 7 | "long_description": "Structural beam element in concrete, steel or wood. Includes formwork, reinforcement and concrete pouring.", 8 | "unit": "m", 9 | "unit_price": 150.00 10 | }, 11 | { 12 | "code": "ESTR002", 13 | "ifc_class": "IfcColumn", 14 | "description": "Column structural element", 15 | "long_description": "Structural column element in concrete, steel or composite. Includes formwork, reinforcement, concrete and finishes.", 16 | "unit": "m", 17 | "unit_price": 180.00 18 | }, 19 | { 20 | "code": "ESTR003", 21 | "ifc_class": "IfcFooting", 22 | "description": "Footing structural element", 23 | "long_description": "Foundation footing element. Includes excavation, lean concrete, reinforcement, concrete and waterproofing.", 24 | "unit": "m3", 25 | "unit_price": 120.00 26 | }, 27 | { 28 | "code": "ESTR004", 29 | "ifc_class": "IfcPile", 30 | "description": "Pile structural element", 31 | "long_description": "Deep foundation pile element. Includes drilling, reinforcement cage and concrete filling.", 32 | "unit": "m", 33 | "unit_price": 200.00 34 | }, 35 | { 36 | "code": "ESTR005", 37 | "ifc_class": "IfcRamp", 38 | "description": "Ramp structural element", 39 | "long_description": "Accessibility ramp structure. Includes concrete base, reinforcement, surface finishing and handrails.", 40 | "unit": "m2", 41 | "unit_price": 140.00 42 | }, 43 | { 44 | "code": "ESTR006", 45 | "ifc_class": "IfcStair", 46 | "description": "Stair structural element", 47 | "long_description": "Staircase unit including structure, steps, risers, handrails and surface finishes.", 48 | "unit": "ud", 49 | "unit_price": 160.00 50 | }, 51 | { 52 | "code": "ALB001", 53 | "ifc_class": "IfcWall", 54 | "description": "Wall masonry element", 55 | "long_description": "Masonry wall construction. Includes brickwork or blockwork, mortar, insulation and plaster finishes on both sides.", 56 | "unit": "m2", 57 | "unit_price": 85.00 58 | }, 59 | { 60 | "code": "ALB002", 61 | "ifc_class": "IfcWallStandardCase", 62 | "description": "WallStandardCase masonry element", 63 | "long_description": "Standard masonry wall with standard layers. Includes structural blocks, thermal insulation and interior/exterior finishes.", 64 | "unit": "m2", 65 | "unit_price": 90.00 66 | }, 67 | { 68 | "code": "FORG001", 69 | "ifc_class": "IfcSlab", 70 | "description": "Slab forging element", 71 | "long_description": "Concrete slab construction. Includes formwork, reinforcement mesh, concrete pouring and surface finishing.", 72 | "unit": "m3", 73 | "unit_price": 110.00 74 | }, 75 | { 76 | "code": "FORG002", 77 | "ifc_class": "IfcRoof", 78 | "description": "Roof forging element", 79 | "long_description": "Roof structure and covering system. Includes structural layer, thermal insulation, waterproofing membrane and protective layer.", 80 | "unit": "m2", 81 | "unit_price": 130.00 82 | }, 83 | { 84 | "code": "FORG003", 85 | "ifc_class": "IfcCovering", 86 | "description": "Covering forging element", 87 | "long_description": "Floor or ceiling covering installation. Includes base preparation, adhesive, covering material and finishing joints.", 88 | "unit": "m2", 89 | "unit_price": 95.00 90 | }, 91 | { 92 | "code": "CARP001", 93 | "ifc_class": "IfcDoor", 94 | "description": "Door carpentry element", 95 | "long_description": "Door unit including frame, door leaf, hinges, lock, handles and finishes. Installation and adjustment included.", 96 | "unit": "ud", 97 | "unit_price": 250.00 98 | }, 99 | { 100 | "code": "CARP002", 101 | "ifc_class": "IfcWindow", 102 | "description": "Window carpentry element", 103 | "long_description": "Window unit with frame, glazing, opening mechanism, weatherstripping and hardware. Installation and sealing included.", 104 | "unit": "ud", 105 | "unit_price": 300.00 106 | }, 107 | { 108 | "code": "INST001", 109 | "ifc_class": "IfcFlowSegment", 110 | "description": "FlowSegment installation element", 111 | "long_description": "Pipe or duct segment for fluid distribution systems. Includes material, fittings, supports and installation.", 112 | "unit": "m", 113 | "unit_price": 75.00 114 | }, 115 | { 116 | "code": "INST002", 117 | "ifc_class": "IfcFlowFitting", 118 | "description": "FlowFitting installation element", 119 | "long_description": "Pipe or duct fitting element (elbow, tee, reducer, etc.). Includes material, gaskets and installation.", 120 | "unit": "ud", 121 | "unit_price": 60.00 122 | }, 123 | { 124 | "code": "INST003", 125 | "ifc_class": "IfcFlowTerminal", 126 | "description": "FlowTerminal installation element", 127 | "long_description": "Terminal device for HVAC or plumbing systems (diffuser, outlet, tap, etc.). Includes device, connections and commissioning.", 128 | "unit": "ud", 129 | "unit_price": 80.00 130 | }, 131 | { 132 | "code": "INST004", 133 | "ifc_class": "IfcDistributionElement", 134 | "description": "DistributionElement installation element", 135 | "long_description": "General distribution system element. Includes component, accessories, supports and installation work.", 136 | "unit": "ud", 137 | "unit_price": 70.00 138 | }, 139 | { 140 | "code": "INST005", 141 | "ifc_class": "IfcRailing", 142 | "description": "Railing installation element", 143 | "long_description": "Railing or guardrail system. Includes posts, handrail, infill panels, anchors and surface treatment.", 144 | "unit": "m", 145 | "unit_price": 100.00 146 | }, 147 | { 148 | "code": "MOB001", 149 | "ifc_class": "IfcFurnishingElement", 150 | "description": "FurnishingElement furniture element", 151 | "long_description": "Built-in furnishing element. Includes manufacturing, finishing, hardware, delivery and installation.", 152 | "unit": "ud", 153 | "unit_price": 120.00 154 | }, 155 | { 156 | "code": "MOB002", 157 | "ifc_class": "IfcFurniture", 158 | "description": "Furniture element", 159 | "long_description": "Furniture unit or piece. Includes product, assembly, adjustment and placement in final position.", 160 | "unit": "ud", 161 | "unit_price": 150.00 162 | } 163 | ] 164 | } 165 | ``` -------------------------------------------------------------------------------- /resources/bc3_helper_files/precios_unitarios.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "prices": [ 3 | { 4 | "code": "ESTR001", 5 | "ifc_class": "IfcBeam", 6 | "description": "Viga estructural", 7 | "long_description": "Elemento de viga estructural en hormigón, acero o madera. Incluye encofrado, armadura y vertido de hormigón.", 8 | "unit": "m", 9 | "unit_price": 150.00 10 | }, 11 | { 12 | "code": "ESTR002", 13 | "ifc_class": "IfcColumn", 14 | "description": "Pilar estructural", 15 | "long_description": "Elemento de pilar estructural en hormigón, acero o mixto. Incluye encofrado, armadura, hormigón y acabados.", 16 | "unit": "m", 17 | "unit_price": 180.00 18 | }, 19 | { 20 | "code": "ESTR003", 21 | "ifc_class": "IfcFooting", 22 | "description": "Zapata de cimentación", 23 | "long_description": "Elemento de zapata de cimentación. Incluye excavación, hormigón de limpieza, armadura, hormigón e impermeabilización.", 24 | "unit": "m3", 25 | "unit_price": 120.00 26 | }, 27 | { 28 | "code": "ESTR004", 29 | "ifc_class": "IfcPile", 30 | "description": "Pilote de cimentación", 31 | "long_description": "Elemento de pilote de cimentación profunda. Incluye perforación, jaula de armado y relleno de hormigón.", 32 | "unit": "m", 33 | "unit_price": 200.00 34 | }, 35 | { 36 | "code": "ESTR005", 37 | "ifc_class": "IfcRamp", 38 | "description": "Rampa de accesibilidad", 39 | "long_description": "Estructura de rampa de accesibilidad. Incluye base de hormigón, armadura, acabado superficial y barandillas.", 40 | "unit": "m2", 41 | "unit_price": 140.00 42 | }, 43 | { 44 | "code": "ESTR006", 45 | "ifc_class": "IfcStair", 46 | "description": "Escalera estructural", 47 | "long_description": "Unidad de escalera incluyendo estructura, peldaños, tabicas, barandillas y acabados superficiales.", 48 | "unit": "ud", 49 | "unit_price": 160.00 50 | }, 51 | { 52 | "code": "ALB001", 53 | "ifc_class": "IfcWall", 54 | "description": "Muro de albañilería", 55 | "long_description": "Construcción de muro de albañilería. Incluye fábrica de ladrillo o bloque, mortero, aislamiento y enfoscado a ambas caras.", 56 | "unit": "m2", 57 | "unit_price": 85.00 58 | }, 59 | { 60 | "code": "ALB002", 61 | "ifc_class": "IfcWallStandardCase", 62 | "description": "Muro estándar de albañilería", 63 | "long_description": "Muro de albañilería con capas estándar. Incluye bloques estructurales, aislamiento térmico y acabados interior/exterior.", 64 | "unit": "m2", 65 | "unit_price": 90.00 66 | }, 67 | { 68 | "code": "FORG001", 69 | "ifc_class": "IfcSlab", 70 | "description": "Forjado de hormigón", 71 | "long_description": "Construcción de forjado de hormigón. Incluye encofrado, malla de armado, vertido de hormigón y acabado superficial.", 72 | "unit": "m3", 73 | "unit_price": 110.00 74 | }, 75 | { 76 | "code": "FORG002", 77 | "ifc_class": "IfcRoof", 78 | "description": "Cubierta", 79 | "long_description": "Sistema de estructura y cobertura de cubierta. Incluye capa estructural, aislamiento térmico, membrana impermeabilizante y capa de protección.", 80 | "unit": "m2", 81 | "unit_price": 130.00 82 | }, 83 | { 84 | "code": "FORG003", 85 | "ifc_class": "IfcCovering", 86 | "description": "Revestimiento", 87 | "long_description": "Instalación de revestimiento de suelo o techo. Incluye preparación de base, adhesivo, material de revestimiento y juntas de acabado.", 88 | "unit": "m2", 89 | "unit_price": 95.00 90 | }, 91 | { 92 | "code": "CARP001", 93 | "ifc_class": "IfcDoor", 94 | "description": "Puerta de carpintería", 95 | "long_description": "Unidad de puerta incluyendo marco, hoja, bisagras, cerradura, manillas y acabados. Instalación y ajuste incluidos.", 96 | "unit": "ud", 97 | "unit_price": 250.00 98 | }, 99 | { 100 | "code": "CARP002", 101 | "ifc_class": "IfcWindow", 102 | "description": "Ventana de carpintería", 103 | "long_description": "Unidad de ventana con marco, acristalamiento, mecanismo de apertura, burletes y herrajes. Instalación y sellado incluidos.", 104 | "unit": "ud", 105 | "unit_price": 300.00 106 | }, 107 | { 108 | "code": "INST001", 109 | "ifc_class": "IfcFlowSegment", 110 | "description": "Tramo de instalación", 111 | "long_description": "Segmento de tubería o conducto para sistemas de distribución de fluidos. Incluye material, accesorios, soportes e instalación.", 112 | "unit": "m", 113 | "unit_price": 75.00 114 | }, 115 | { 116 | "code": "INST002", 117 | "ifc_class": "IfcFlowFitting", 118 | "description": "Accesorio de instalación", 119 | "long_description": "Elemento de accesorio de tubería o conducto (codo, te, reducción, etc.). Incluye material, juntas e instalación.", 120 | "unit": "ud", 121 | "unit_price": 60.00 122 | }, 123 | { 124 | "code": "INST003", 125 | "ifc_class": "IfcFlowTerminal", 126 | "description": "Terminal de instalación", 127 | "long_description": "Dispositivo terminal para sistemas de climatización o fontanería (difusor, salida, grifo, etc.). Incluye dispositivo, conexiones y puesta en marcha.", 128 | "unit": "ud", 129 | "unit_price": 80.00 130 | }, 131 | { 132 | "code": "INST004", 133 | "ifc_class": "IfcDistributionElement", 134 | "description": "Elemento de distribución", 135 | "long_description": "Elemento general de sistema de distribución. Incluye componente, accesorios, soportes y trabajos de instalación.", 136 | "unit": "ud", 137 | "unit_price": 70.00 138 | }, 139 | { 140 | "code": "INST005", 141 | "ifc_class": "IfcRailing", 142 | "description": "Barandilla", 143 | "long_description": "Sistema de barandilla o guarda. Incluye postes, pasamanos, paneles de relleno, anclajes y tratamiento superficial.", 144 | "unit": "m", 145 | "unit_price": 100.00 146 | }, 147 | { 148 | "code": "MOB001", 149 | "ifc_class": "IfcFurnishingElement", 150 | "description": "Elemento de mobiliario", 151 | "long_description": "Elemento de mobiliario fijo. Incluye fabricación, acabados, herrajes, entrega e instalación.", 152 | "unit": "ud", 153 | "unit_price": 120.00 154 | }, 155 | { 156 | "code": "MOB002", 157 | "ifc_class": "IfcFurniture", 158 | "description": "Mueble", 159 | "long_description": "Unidad o pieza de mobiliario. Incluye producto, montaje, ajuste y colocación en posición final.", 160 | "unit": "ud", 161 | "unit_price": 150.00 162 | } 163 | ] 164 | } 165 | ``` -------------------------------------------------------------------------------- /bc3_writer.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | from pathlib import Path 3 | from typing import Dict, List, Any, Union 4 | from datetime import datetime 5 | from collections import defaultdict 6 | 7 | 8 | # Base path for BC3 helper files 9 | BASE_PATH = Path(__file__).parent / 'resources' / 'bc3_helper_files' 10 | 11 | 12 | class IFC2BC3Converter: 13 | """ 14 | Converts IFC structures (JSON) to BC3 format for construction budgets. 15 | 16 | Architecture: 17 | - Loads and validates input data (IFC structure, quantities, prices) 18 | - Generates chapter hierarchy from IFC spatial structure 19 | - Groups building elements into budget items by type 20 | - Exports to FIEBDC-3 (BC3) format with windows-1252 encoding 21 | 22 | Method Groups: 23 | 1. Initialization & Configuration Loading 24 | 2. Data Parsing & Indexing 25 | 3. Code Generation & Formatting 26 | 4. IFC Element Classification 27 | 5. Quantity & Measurement Extraction 28 | 6. BC3 Record Building 29 | 7. Core Processing & Conversion Logic 30 | 8. Public API Methods 31 | """ 32 | 33 | # Class constants 34 | SPATIAL_TYPES = {'IfcProject', 'IfcSite', 'IfcBuilding', 'IfcBuildingStorey', 'IfcBridge', 'IfcBridgePart'} 35 | IGNORED_TYPES = {'IfcSpace', 'IfcAnnotation', 'IfcGrid', 'IfcAxis'} 36 | 37 | def __init__(self, structure_data: Union[str, Dict], quantities_data: Union[str, Dict], 38 | language: str = 'es'): 39 | """ 40 | Initializes the converter with input data. 41 | 42 | Args: 43 | structure_data: JSON string or dict with IFC structure 44 | quantities_data: JSON string or dict with IFC quantities 45 | language: Language for the budget ('es' or 'en'). Default 'es' 46 | """ 47 | # Parse input data 48 | self.structure_data = self._parse_json_input(structure_data) 49 | self.quantities_data = self._parse_json_input(quantities_data) 50 | self.quantities_by_id = self._index_quantities() 51 | 52 | # Configuration 53 | self.language = language 54 | self.unit_prices = self._load_unit_prices() 55 | self.spatial_labels = self._load_spatial_labels() 56 | self.element_categories = self._load_element_categories() 57 | 58 | # Counters using defaultdict for simplification 59 | self.chapter_counters = defaultdict(int) 60 | self.item_counters = defaultdict(int) 61 | 62 | # Registry of items and positions 63 | self.items_per_chapter = defaultdict(set) 64 | self.item_positions = defaultdict(dict) 65 | 66 | # Global registry of created concepts (to avoid duplicates) 67 | self.created_concepts = set() 68 | 69 | # Invert mapping for O(1) lookup 70 | self._ifc_to_category = self._build_ifc_category_map() 71 | 72 | # Cache for code-to-position conversions 73 | self._position_cache = {} 74 | 75 | # ============================================================================ 76 | # 1. INITIALIZATION & CONFIGURATION LOADING 77 | # ============================================================================ 78 | # Methods that load external configuration from JSON files and build 79 | # internal data structures during initialization. 80 | 81 | def _load_unit_prices(self) -> Dict[str, Dict]: 82 | """ 83 | Loads unit prices from JSON file based on language. 84 | Optimized: Loads all prices at once (more efficient than lazy loading 85 | since typically most types are used in an IFC model). 86 | 87 | Returns: 88 | Dict with ifc_class as key and dict {code, description, long_description, unit, price} as value 89 | """ 90 | filename = 'precios_unitarios.json' if self.language == 'es' else 'unit_prices.json' 91 | prices_path = BASE_PATH / filename 92 | 93 | if not prices_path.exists(): 94 | print(f"Warning: Unit prices file not found at {prices_path}") 95 | return {} 96 | 97 | try: 98 | with open(prices_path, 'r', encoding='utf-8') as f: 99 | data = json.load(f) 100 | # Dict comprehension is faster than loop + assignment 101 | return { 102 | item['ifc_class']: { 103 | 'code': item['code'], 104 | 'description': item['description'], 105 | 'long_description': item['long_description'], 106 | 'unit': item['unit'], 107 | 'price': item['unit_price'] 108 | } 109 | for item in data.get('prices', []) 110 | } 111 | except Exception as e: 112 | print(f"Error loading unit prices: {e}") 113 | return {} 114 | 115 | def _load_spatial_labels(self) -> Dict[str, str]: 116 | """ 117 | Loads spatial element labels from JSON file according to language. 118 | 119 | Returns: 120 | Dict with IFC type as key and translated label as value 121 | """ 122 | filename = f'spatial_labels_{self.language}.json' 123 | labels_path = BASE_PATH / filename 124 | 125 | if not Path(labels_path).exists(): 126 | print(f"Warning: Spatial labels file not found at {labels_path}") 127 | return {} 128 | 129 | try: 130 | with open(labels_path, 'r', encoding='utf-8') as f: 131 | data = json.load(f) 132 | return data.get('spatial_labels', {}) 133 | except Exception as e: 134 | print(f"Error loading spatial labels: {e}") 135 | return {} 136 | 137 | def _load_element_categories(self) -> Dict[str, set]: 138 | """ 139 | Loads element categories from JSON file. 140 | 141 | Returns: 142 | Dict with category code as key and set of IFC types as value 143 | """ 144 | categories_path = BASE_PATH / 'element_categories.json' 145 | 146 | if not Path(categories_path).exists(): 147 | print(f"Warning: Element categories file not found at {categories_path}") 148 | return {} 149 | 150 | try: 151 | with open(categories_path, 'r', encoding='utf-8') as f: 152 | data = json.load(f) 153 | # Convert lists to sets for O(1) membership testing 154 | return { 155 | category: set(ifc_types) 156 | for category, ifc_types in data.get('element_categories', {}).items() 157 | } 158 | except Exception as e: 159 | print(f"Error loading element categories: {e}") 160 | return {} 161 | 162 | def _build_ifc_category_map(self) -> Dict[str, str]: 163 | """Builds reverse mapping of IFC type -> category for O(1) lookup.""" 164 | return { 165 | ifc_type: category 166 | for category, types in self.element_categories.items() 167 | for ifc_type in types 168 | } 169 | 170 | # ============================================================================ 171 | # 2. DATA PARSING & INDEXING 172 | # ============================================================================ 173 | # Methods that parse and index input data for efficient access during 174 | # conversion process. 175 | 176 | @staticmethod 177 | def _parse_json_input(data: Union[str, Dict]) -> Dict: 178 | """Parses input that can be JSON string or dict.""" 179 | return json.loads(data) if isinstance(data, str) else data 180 | 181 | def _index_quantities(self) -> Dict[str, Dict]: 182 | """Indexes quantities by element ID for O(1) access.""" 183 | elements = self.quantities_data.get('elements', []) 184 | return {elem['id']: elem for elem in elements} 185 | 186 | # ============================================================================ 187 | # 3. CODE GENERATION & FORMATTING 188 | # ============================================================================ 189 | # Methods that generate hierarchical codes, format positions, and escape 190 | # text for BC3 format compliance. 191 | 192 | def _generate_chapter_code(self, parent_code: str = '') -> str: 193 | """Generates a hierarchical chapter code.""" 194 | # Root level uses sequential numbering: 01#, 02#, 03#... 195 | if parent_code == 'R_A_I_Z##': 196 | self.chapter_counters['root'] += 1 197 | return f'{self.chapter_counters["root"]:02d}#' 198 | 199 | # Sub-levels use hierarchical notation: 01.01#, 01.01.01#... 200 | base_code = parent_code.rstrip('#') 201 | self.chapter_counters[base_code] += 1 202 | return f'{base_code}.{self.chapter_counters[base_code]:02d}#' 203 | 204 | def _generate_item_code(self, category: str, chapter_code: str = None) -> str: 205 | """Generates a unique code for a budget item globally (not per chapter).""" 206 | # Use only category as key to ensure global uniqueness 207 | self.item_counters[category] += 1 208 | return f"{category}{self.item_counters[category]:03d}" 209 | 210 | def _chapter_code_to_position(self, chapter_code: str) -> str: 211 | """ 212 | Converts chapter code to position format with caching. 213 | Example: '01.02.03#' -> '1\\2\\3' 214 | """ 215 | if chapter_code in self._position_cache: 216 | return self._position_cache[chapter_code] 217 | 218 | clean_code = chapter_code.rstrip('#') 219 | parts = clean_code.split('.') 220 | position_parts = [str(int(part)) for part in parts] 221 | result = '\\'.join(position_parts) 222 | 223 | self._position_cache[chapter_code] = result 224 | return result 225 | 226 | @staticmethod 227 | def _escape_bc3_text(text: str) -> str: 228 | """Escapes special characters for BC3 format.""" 229 | if not text: 230 | return '' 231 | # Normalize and clean whitespace 232 | text = str(text).strip().replace('\r', ' ').replace('\n', ' ').replace('\t', ' ') 233 | # Escape BC3 special characters 234 | return text.replace('|', ' ').replace('~', '-') 235 | 236 | # ============================================================================ 237 | # 4. IFC ELEMENT CLASSIFICATION 238 | # ============================================================================ 239 | # Methods that classify, categorize and filter IFC elements based on their 240 | # type and properties. 241 | 242 | def _get_category_code(self, ifc_type: str) -> str: 243 | """Gets category code for an IFC type (O(1) lookup).""" 244 | return self._ifc_to_category.get(ifc_type, 'OTROS') 245 | 246 | def _get_spatial_element_label(self, ifc_type: str) -> str: 247 | """Gets translated label for spatial elements from loaded JSON.""" 248 | return self.spatial_labels.get(ifc_type, ifc_type) 249 | 250 | @classmethod 251 | def _is_spatial_element(cls, ifc_type: str) -> bool: 252 | """Determines if an IFC element is spatial (container).""" 253 | return ifc_type in cls.SPATIAL_TYPES 254 | 255 | @classmethod 256 | def _is_ignored_element(cls, ifc_type: str) -> bool: 257 | """Determines if an element should be ignored.""" 258 | return ifc_type in cls.IGNORED_TYPES 259 | 260 | def _group_elements_by_type(self, elements: List[Dict]) -> Dict[str, List[Dict]]: 261 | """Groups elements by IFC type, ignoring invalid types.""" 262 | groups = defaultdict(list) 263 | for elem in elements: 264 | if not self._is_ignored_element(elem['type']): 265 | groups[elem['type']].append(elem) 266 | return groups 267 | 268 | def _is_unit_based_element(self, ifc_type: str) -> bool: 269 | """ 270 | Determines if an element is measured by unit (no dimensions needed). 271 | Unit-based elements: doors, windows, furniture, stairs, railings, fittings, terminals. 272 | """ 273 | unit_based_types = { 274 | 'IfcDoor', 'IfcWindow', # CARP - Carpentry 275 | 'IfcFurnishingElement', 'IfcFurniture', # MOB - Furniture 276 | 'IfcStair', # ESTR - Stairs (counted as units) 277 | 'IfcFlowFitting', 'IfcFlowTerminal', 'IfcDistributionElement', 'IfcRailing' # INST - Installations 278 | } 279 | return ifc_type in unit_based_types 280 | 281 | def _is_linear_element(self, ifc_type: str) -> bool: 282 | """ 283 | Determines if an element is measured by length (meters). 284 | Linear elements: beams, columns, piles. 285 | """ 286 | linear_types = { 287 | 'IfcBeam', # ESTR - Beams 288 | 'IfcColumn', # ESTR - Columns 289 | 'IfcPile' # ESTR - Piles 290 | } 291 | return ifc_type in linear_types 292 | 293 | # ============================================================================ 294 | # 5. QUANTITY & MEASUREMENT EXTRACTION 295 | # ============================================================================ 296 | # Methods that extract quantities, dimensions, and measurements from IFC 297 | # elements and format them for BC3 records. 298 | 299 | def _get_quantities_for_element(self, element_id: str) -> Dict[str, float]: 300 | """Gets quantities for an element.""" 301 | return self.quantities_by_id.get(element_id, {}).get('quantities', {}) 302 | 303 | @staticmethod 304 | def _get_measurement_dimensions(quantities: Dict[str, float], ifc_type: str = None) -> tuple: 305 | """ 306 | Extracts dimensions from quantities based on element type. 307 | - Walls (IfcWall*): Use NetSideArea (accounts for doors/windows) 308 | - Slabs/Roofs (IfcSlab, IfcRoof): Use GrossVolume 309 | - Other elements: Use NetVolume or fallback values 310 | Returns (units, length, width, height) 311 | """ 312 | if not quantities: 313 | return (1.0, 0.0, 0.0, 0.0) 314 | 315 | # Walls: ONLY use NetSideArea (lateral area without openings) 316 | if ifc_type and ifc_type.startswith('IfcWall'): 317 | net_side_area = quantities.get('NetSideArea', 0.0) 318 | # Force return NetSideArea for walls, even if 0 319 | return (1.0, net_side_area, 0.0, 0.0) 320 | 321 | # Slabs and Roofs: ONLY use GrossVolume 322 | if ifc_type in ('IfcSlab', 'IfcRoof'): 323 | gross_volume = quantities.get('GrossVolume', 0.0) 324 | # Force return GrossVolume for slabs/roofs, even if 0 325 | return (1.0, gross_volume, 0.0, 0.0) 326 | 327 | # Priority 1: Use NetVolume (accounts for openings and voids) 328 | net_volume = quantities.get('NetVolume', 0.0) 329 | if net_volume > 0: 330 | return (1.0, net_volume, 0.0, 0.0) 331 | 332 | # Priority 2: Use NetSideArea as fallback 333 | net_side_area = quantities.get('NetSideArea', 0.0) 334 | if net_side_area > 0: 335 | return (1.0, net_side_area, 0.0, 0.0) 336 | 337 | # Priority 3: Use GrossVolume or GrossSideArea as fallback 338 | gross_volume = quantities.get('GrossVolume', 0.0) 339 | gross_side_area = quantities.get('GrossSideArea', 0.0) 340 | 341 | if gross_volume > 0: 342 | return (1.0, gross_volume, 0.0, 0.0) 343 | elif gross_side_area > 0: 344 | return (1.0, gross_side_area, 0.0, 0.0) 345 | 346 | # Priority 4: Use basic dimensions (for linear elements) 347 | length = quantities.get('Length', 0.0) 348 | width = quantities.get('Width', 0.0) 349 | height = quantities.get('Height', 0.0) 350 | 351 | return (1.0, length, width, height) 352 | 353 | def _get_item_data(self, ifc_type: str, category: str, chapter_code: str) -> Dict[str, Any]: 354 | """Gets all necessary data to create a budget item.""" 355 | price_data = self.unit_prices.get(ifc_type, {}) 356 | return { 357 | 'code': price_data.get('code', self._generate_item_code(category, chapter_code)), 358 | 'description': price_data.get('description', ifc_type.replace('Ifc', '')), 359 | 'long_description': price_data.get('long_description', f"Item for {ifc_type}"), 360 | 'unit': price_data.get('unit', 'ud'), 361 | 'price': price_data.get('price', 100.0) 362 | } 363 | 364 | def _create_measurement_lines(self, elements: List[Dict], ifc_type: str) -> List[str]: 365 | """Creates measurement lines for a list of elements, sorted alphabetically by name.""" 366 | # Sort elements by name before processing (handle None values) 367 | sorted_elements = sorted(elements, key=lambda e: e.get('name') or '') 368 | 369 | measurement_lines = [] 370 | for idx, elem in enumerate(sorted_elements, 1): 371 | elem_name = self._escape_bc3_text(elem.get('name', f'Element {idx}')) 372 | 373 | # Elements measured by unit (doors, windows, furniture) don't need dimensions 374 | if self._is_unit_based_element(ifc_type): 375 | line_parts = [elem_name, "1.000", "", "", ""] 376 | # Linear elements (beams, columns, piles) measured by length 377 | elif self._is_linear_element(ifc_type): 378 | quantities = self._get_quantities_for_element(elem['id']) 379 | length = quantities.get('Length', 0.0) 380 | line_parts = [ 381 | elem_name, 382 | "1.000", 383 | f"{length:.2f}" if length > 0 else "", 384 | "", 385 | "" 386 | ] 387 | else: 388 | quantities = self._get_quantities_for_element(elem['id']) 389 | units, length, width, height = self._get_measurement_dimensions(quantities, ifc_type) 390 | line_parts = [ 391 | elem_name, 392 | f"{units:.3f}", 393 | f"{length:.2f}" if length > 0 else "", 394 | f"{width:.2f}" if width > 0 else "", 395 | f"{height:.2f}" if height > 0 else "" 396 | ] 397 | 398 | measurement_lines.append('\\'.join(line_parts)) 399 | 400 | return measurement_lines 401 | 402 | # ============================================================================ 403 | # 6. BC3 RECORD BUILDING 404 | # ============================================================================ 405 | # Methods that construct individual BC3 format records (~V, ~K, ~C, ~D, ~T, ~M). 406 | # These are the low-level builders for BC3 file structure. 407 | 408 | @staticmethod 409 | def _create_bc3_header() -> List[str]: 410 | """Creates BC3 file header lines.""" 411 | date_code = datetime.now().strftime('%d%m%Y') 412 | return [ 413 | f'~V||FIEBDC-3/2016\\{date_code}|IFC2BC3 Converter|\\|ANSI||', 414 | '~K|3\\3\\3\\2\\2\\2\\2\\2\\|0\\0\\0\\0\\0\\|3\\2\\\\2\\2\\\\2\\2\\2\\3\\3\\3\\3\\2\\EUR\\|' 415 | ] 416 | 417 | def _build_chapter_record(self, code: str, name: str) -> str: 418 | """Builds ~C record for a chapter.""" 419 | return f"~C|{code}\\||{name}|0\\||||||" 420 | 421 | def _build_decomposition_record(self, code: str, child_codes: List[str]) -> str: 422 | """Builds ~D decomposition record.""" 423 | children_str = '\\'.join([f"{c}\\\\1.000" for c in child_codes]) 424 | return f"~D|{code}|{children_str}|" 425 | 426 | def _build_item_record(self, code: str, unit: str, name: str, price: float, date: str) -> str: 427 | """Builds ~C record for a budget item.""" 428 | return f"~C|{code}|{unit}|{name}|{price:.2f}||{date}|" 429 | 430 | def _build_text_record(self, code: str, description: str) -> str: 431 | """Builds ~T descriptive text record.""" 432 | return f"~T|{code}|{description}|" 433 | 434 | def _build_measurement_record(self, chapter_code: str, item_code: str, 435 | position: str, measurement_content: str) -> str: 436 | """Builds ~M measurements record.""" 437 | return f"~M|{chapter_code}\\{item_code}|{position}|0|\\{measurement_content}\\|" 438 | 439 | # ============================================================================ 440 | # 7. CORE PROCESSING & CONVERSION LOGIC 441 | # ============================================================================ 442 | # Methods that orchestrate the conversion process by processing spatial 443 | # structure and building elements recursively. 444 | 445 | def _process_spatial_node(self, node: Dict, parent_code: str, lines: List[str], depth: int = 0) -> str: 446 | """Recursively processes a spatial node (chapter) from IFC structure.""" 447 | if self._is_ignored_element(node['type']): 448 | return None 449 | 450 | # Generate chapter code and name 451 | code = 'R_A_I_Z##' if depth == 0 else self._generate_chapter_code(parent_code) 452 | 453 | label = self._get_spatial_element_label(node['type']) 454 | node_name = node.get('name', '') 455 | full_name = f"{label} - {node_name}" if node_name else label 456 | name = self._escape_bc3_text(full_name) 457 | 458 | # Add chapter record 459 | lines.append(self._build_chapter_record(code, name)) 460 | 461 | decomposition_codes = [] 462 | 463 | # Process building elements 464 | building_elements = node.get('building_elements', []) 465 | if building_elements: 466 | item_codes = self._process_building_elements(building_elements, code, lines) 467 | decomposition_codes.extend(item_codes) 468 | 469 | # Process spatial children recursively 470 | for child in node.get('children', []): 471 | if self._is_spatial_element(child['type']): 472 | child_code = self._process_spatial_node(child, code, lines, depth + 1) 473 | if child_code: 474 | decomposition_codes.append(child_code) 475 | 476 | # Add decomposition record 477 | if decomposition_codes: 478 | lines.append(self._build_decomposition_record(code, decomposition_codes)) 479 | 480 | return code 481 | 482 | def _process_building_elements(self, elements: List[Dict], chapter_code: str, lines: List[str]) -> List[str]: 483 | """ 484 | Processes building elements and groups them by category. 485 | Optimized: Batch operations to reduce concatenation overhead. 486 | """ 487 | created_items = [] 488 | chapter_key = chapter_code.rstrip('#') 489 | 490 | # Group elements by type 491 | elements_by_type = self._group_elements_by_type(elements) 492 | 493 | # Pre-calculate common values outside loop 494 | chapter_position = self._chapter_code_to_position(chapter_code) 495 | date_str = datetime.now().strftime("%d%m%Y") 496 | 497 | # Process each group 498 | for ifc_type, type_elements in elements_by_type.items(): 499 | category = self._get_category_code(ifc_type) 500 | item_key = f"{ifc_type}_{chapter_key}" 501 | 502 | # Check if this item already exists in this chapter 503 | if item_key in self.items_per_chapter[chapter_key]: 504 | continue 505 | 506 | self.items_per_chapter[chapter_key].add(item_key) 507 | 508 | # Get item data 509 | item_data = self._get_item_data(ifc_type, category, chapter_code) 510 | item_code = item_data['code'] 511 | 512 | # Register position 513 | position = len(self.item_positions[chapter_key]) + 1 514 | self.item_positions[chapter_key][item_code] = position 515 | 516 | # Escape texts (batch) 517 | name = self._escape_bc3_text(item_data['description']) 518 | long_desc = self._escape_bc3_text(item_data['long_description']) 519 | 520 | batch_records = [] 521 | 522 | # Only create ~C and ~T records if this concept hasn't been created globally 523 | if item_code not in self.created_concepts: 524 | self.created_concepts.add(item_code) 525 | batch_records.extend([ 526 | self._build_item_record(item_code, item_data['unit'], name, item_data['price'], date_str), 527 | self._build_text_record(item_code, long_desc) 528 | ]) 529 | 530 | # Always create measurements for this chapter 531 | measurement_lines = self._create_measurement_lines(type_elements, ifc_type) 532 | full_position = f"{chapter_position}\\{position}" 533 | measurement_content = '\\\\'.join(measurement_lines) 534 | 535 | batch_records.append( 536 | self._build_measurement_record(chapter_code, item_code, full_position, measurement_content) 537 | ) 538 | 539 | # Add batch at once (more efficient than 3 individual appends) 540 | lines.extend(batch_records) 541 | created_items.append(item_code) 542 | 543 | return created_items 544 | 545 | # ============================================================================ 546 | # 8. PUBLIC API METHODS 547 | # ============================================================================ 548 | # Public methods that provide the main interface for converting and 549 | # exporting BC3 files. 550 | 551 | def convert(self) -> str: 552 | """Performs complete conversion and returns BC3 file content.""" 553 | lines = self._create_bc3_header() 554 | 555 | # Process structure from root 556 | # Try different possible root keys 557 | root = self.structure_data.get('structure') 558 | if not root and 'type' in self.structure_data: 559 | # If structure_data itself is the root node 560 | root = self.structure_data 561 | 562 | if root: 563 | self._process_spatial_node(root, '', lines, depth=0) 564 | else: 565 | print(f"Warning: No structure found. Keys available: {list(self.structure_data.keys())}") 566 | 567 | return '\n'.join(lines) 568 | 569 | def export(self, output_filename: str = 'ifc2bc3.bc3'): 570 | """Exports BC3 file to exports folder.""" 571 | script_dir = Path(__file__).parent 572 | exports_dir = script_dir / 'exports' 573 | exports_dir.mkdir(exist_ok=True) 574 | 575 | bc3_content = self.convert() 576 | output_path = exports_dir / output_filename 577 | 578 | with open(output_path, 'w', encoding='windows-1252', newline='\r\n', errors='strict') as f: 579 | f.write(bc3_content) 580 | 581 | print(f"BC3 file successfully exported: {output_path}") 582 | return output_path 583 | ``` -------------------------------------------------------------------------------- /tools.py: -------------------------------------------------------------------------------- ```python 1 | # blender_mcp_server.py 2 | from mcp.server.fastmcp import FastMCP, Context, Image 3 | import socket 4 | import json 5 | import asyncio 6 | import logging 7 | from dataclasses import dataclass 8 | from contextlib import asynccontextmanager 9 | from typing import AsyncIterator, Dict, Any, List, TypedDict 10 | import os 11 | from pathlib import Path 12 | import base64 13 | from urllib.parse import urlparse 14 | from typing import Optional 15 | import sys 16 | from bc3_writer import IFC2BC3Converter 17 | 18 | 19 | # Configure logging 20 | logging.basicConfig(level=logging.INFO, 21 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 22 | logger = logging.getLogger("BlenderMCPServer") 23 | 24 | @dataclass 25 | class BlenderConnection: 26 | host: str 27 | port: int 28 | sock: socket.socket = None # Changed from 'socket' to 'sock' to avoid naming conflict 29 | 30 | def connect(self) -> bool: 31 | """Connect to the Blender addon socket server""" 32 | if self.sock: 33 | return True 34 | 35 | try: 36 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 37 | self.sock.connect((self.host, self.port)) 38 | logger.info(f"Connected to Blender at {self.host}:{self.port}") 39 | return True 40 | except Exception as e: 41 | logger.error(f"Failed to connect to Blender: {str(e)}") 42 | self.sock = None 43 | return False 44 | 45 | def disconnect(self): 46 | """Disconnect from the Blender addon""" 47 | if self.sock: 48 | try: 49 | self.sock.close() 50 | except Exception as e: 51 | logger.error(f"Error disconnecting from Blender: {str(e)}") 52 | finally: 53 | self.sock = None 54 | 55 | def receive_full_response(self, sock, buffer_size=8192): 56 | """Receive the complete response, potentially in multiple chunks""" 57 | chunks = [] 58 | # Use a consistent timeout value that matches the addon's timeout 59 | sock.settimeout(15.0) # Match the addon's timeout 60 | 61 | try: 62 | while True: 63 | try: 64 | chunk = sock.recv(buffer_size) 65 | if not chunk: 66 | # If we get an empty chunk, the connection might be closed 67 | if not chunks: # If we haven't received anything yet, this is an error 68 | raise Exception("Connection closed before receiving any data") 69 | break 70 | 71 | chunks.append(chunk) 72 | 73 | # Check if we've received a complete JSON object 74 | try: 75 | data = b''.join(chunks) 76 | json.loads(data.decode('utf-8')) 77 | # If we get here, it parsed successfully 78 | logger.info(f"Received complete response ({len(data)} bytes)") 79 | return data 80 | except json.JSONDecodeError: 81 | # Incomplete JSON, continue receiving 82 | continue 83 | except socket.timeout: 84 | # If we hit a timeout during receiving, break the loop and try to use what we have 85 | logger.warning("Socket timeout during chunked receive") 86 | break 87 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e: 88 | logger.error(f"Socket connection error during receive: {str(e)}") 89 | raise # Re-raise to be handled by the caller 90 | except socket.timeout: 91 | logger.warning("Socket timeout during chunked receive") 92 | except Exception as e: 93 | logger.error(f"Error during receive: {str(e)}") 94 | raise 95 | 96 | # If we get here, we either timed out or broke out of the loop 97 | # Try to use what we have 98 | if chunks: 99 | data = b''.join(chunks) 100 | logger.info(f"Returning data after receive completion ({len(data)} bytes)") 101 | try: 102 | # Try to parse what we have 103 | json.loads(data.decode('utf-8')) 104 | return data 105 | except json.JSONDecodeError: 106 | # If we can't parse it, it's incomplete 107 | raise Exception("Incomplete JSON response received") 108 | else: 109 | raise Exception("No data received") 110 | 111 | def send_command(self, command_type: str, params: Dict[str, Any] | None = None) -> Dict[str, Any]: 112 | """Send a command to Blender and return the response""" 113 | if not self.sock and not self.connect(): 114 | raise ConnectionError("Not connected to Blender") 115 | 116 | command = { 117 | "type": command_type, 118 | "params": params or {} 119 | } 120 | 121 | try: 122 | # Log the command being sent 123 | logger.info(f"Sending command: {command_type} with params: {params}") 124 | 125 | # Send the command 126 | self.sock.sendall(json.dumps(command).encode('utf-8')) 127 | logger.info(f"Command sent, waiting for response...") 128 | 129 | # Set a timeout for receiving - use the same timeout as in receive_full_response 130 | self.sock.settimeout(15.0) # Match the addon's timeout 131 | 132 | # Receive the response using the improved receive_full_response method 133 | response_data = self.receive_full_response(self.sock) 134 | logger.info(f"Received {len(response_data)} bytes of data") 135 | 136 | response = json.loads(response_data.decode('utf-8')) 137 | logger.info(f"Response parsed, status: {response.get('status', 'unknown')}") 138 | 139 | if response.get("status") == "error": 140 | logger.error(f"Blender error: {response.get('message')}") 141 | raise Exception(response.get("message", "Unknown error from Blender")) 142 | 143 | return response.get("result", {}) 144 | except socket.timeout: 145 | logger.error("Socket timeout while waiting for response from Blender") 146 | # Don't try to reconnect here - let the get_blender_connection handle reconnection 147 | # Just invalidate the current socket so it will be recreated next time 148 | self.sock = None 149 | raise Exception("Timeout waiting for Blender response - try simplifying your request") 150 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e: 151 | logger.error(f"Socket connection error: {str(e)}") 152 | self.sock = None 153 | raise Exception(f"Connection to Blender lost: {str(e)}") 154 | except json.JSONDecodeError as e: 155 | logger.error(f"Invalid JSON response from Blender: {str(e)}") 156 | # Try to log what was received 157 | if 'response_data' in locals() and response_data: 158 | logger.error(f"Raw response (first 200 bytes): {response_data[:200]}") 159 | raise Exception(f"Invalid response from Blender: {str(e)}") 160 | except Exception as e: 161 | logger.error(f"Error communicating with Blender: {str(e)}") 162 | # Don't try to reconnect here - let the get_blender_connection handle reconnection 163 | self.sock = None 164 | raise Exception(f"Communication error with Blender: {str(e)}") 165 | 166 | @asynccontextmanager 167 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 168 | """Manage server startup and shutdown lifecycle""" 169 | # We don't need to create a connection here since we're using the global connection 170 | # for resources and tools 171 | 172 | try: 173 | # Just log that we're starting up 174 | logger.info("BlenderMCP server starting up") 175 | 176 | # Try to connect to Blender on startup to verify it's available 177 | try: 178 | # This will initialize the global connection if needed 179 | blender = get_blender_connection() 180 | logger.info("Successfully connected to Blender on startup") 181 | except Exception as e: 182 | logger.warning(f"Could not connect to Blender on startup: {str(e)}") 183 | logger.warning("Make sure the Blender addon is running before using Blender resources or tools") 184 | 185 | # Return an empty context - we're using the global connection 186 | yield {} 187 | finally: 188 | # Clean up the global connection on shutdown 189 | global _blender_connection 190 | if _blender_connection: 191 | logger.info("Disconnecting from Blender on shutdown") 192 | _blender_connection.disconnect() 193 | _blender_connection = None 194 | logger.info("BlenderMCP server shut down") 195 | 196 | # Create the MCP server with lifespan support 197 | mcp = FastMCP( 198 | "Bonsai MCP", 199 | description="IFC manipulation through Blender and MCP", 200 | lifespan=server_lifespan 201 | ) 202 | 203 | # Resource endpoints 204 | 205 | # Global connection for resources (since resources can't access context) 206 | _blender_connection = None 207 | 208 | def get_blender_connection(): 209 | """Get or create a persistent Blender connection""" 210 | global _blender_connection 211 | 212 | # If we have an existing connection, check if it's still valid 213 | if _blender_connection is not None: 214 | try: 215 | # Simple ping to check if connection is still alive 216 | _blender_connection.send_command("get_ifc_project_info") 217 | return _blender_connection 218 | except Exception as e: 219 | # Connection is dead, close it and create a new one 220 | logger.warning(f"Existing connection is no longer valid: {str(e)}") 221 | try: 222 | _blender_connection.disconnect() 223 | except: 224 | pass 225 | _blender_connection = None 226 | 227 | # Create a new connection if needed 228 | if _blender_connection is None: 229 | _blender_connection = BlenderConnection(host="localhost", port=9876) 230 | if not _blender_connection.connect(): 231 | logger.error("Failed to connect to Blender") 232 | _blender_connection = None 233 | raise Exception("Could not connect to Blender. Make sure the Blender addon is running.") 234 | logger.info("Created new persistent connection to Blender") 235 | 236 | return _blender_connection 237 | 238 | # ------------------------------- 239 | # MCP TOOLS 240 | # ------------------------------- 241 | 242 | @mcp.tool() 243 | def execute_blender_code(ctx: Context, code: str) -> str: 244 | """ 245 | Execute arbitrary Python code in Blender. 246 | 247 | Parameters: 248 | - code: The Python code to execute 249 | """ 250 | try: 251 | # Get the global connection 252 | blender = get_blender_connection() 253 | 254 | result = blender.send_command("execute_code", {"code": code}) 255 | return f"Code executed successfully: {result.get('result', '')}" 256 | except Exception as e: 257 | logger.error(f"Error executing code: {str(e)}") 258 | return f"Error executing code: {str(e)}" 259 | 260 | 261 | ### IFC Tools 262 | @mcp.tool() 263 | def get_ifc_project_info() -> str: 264 | """ 265 | Get basic information about the IFC project, including name, description, 266 | and counts of different entity types. 267 | 268 | Returns: 269 | A JSON-formatted string with project information 270 | """ 271 | try: 272 | blender = get_blender_connection() 273 | result = blender.send_command("get_ifc_project_info") 274 | 275 | # Return the formatted JSON of the results 276 | return json.dumps(result, indent=2) 277 | except Exception as e: 278 | logger.error(f"Error getting IFC project info: {str(e)}") 279 | return f"Error getting IFC project info: {str(e)}" 280 | 281 | @mcp.tool() 282 | def get_selected_ifc_entities() -> str: 283 | """ 284 | Get IFC entities corresponding to the currently selected objects in Blender. 285 | This allows working specifically with objects the user has manually selected in the Blender UI. 286 | 287 | Returns: 288 | A JSON-formatted string with information about the selected IFC entities 289 | """ 290 | try: 291 | blender = get_blender_connection() 292 | result = blender.send_command("get_selected_ifc_entities") 293 | 294 | # Return the formatted JSON of the results 295 | return json.dumps(result, indent=2) 296 | except Exception as e: 297 | logger.error(f"Error getting selected IFC entities: {str(e)}") 298 | return f"Error getting selected IFC entities: {str(e)}" 299 | 300 | # Modify the existing list_ifc_entities function to accept a selected_only parameter 301 | @mcp.tool() 302 | def list_ifc_entities(entity_type: str | None = None, limit: int = 50, selected_only: bool = False) -> str: 303 | """ 304 | List IFC entities of a specific type. Can be filtered to only include objects 305 | currently selected in the Blender UI. 306 | 307 | Args: 308 | entity_type: Type of IFC entity to list (e.g., "IfcWall") 309 | limit: Maximum number of entities to return 310 | selected_only: If True, only return information about selected objects 311 | 312 | Returns: 313 | A JSON-formatted string listing the specified entities 314 | """ 315 | try: 316 | blender = get_blender_connection() 317 | result = blender.send_command("list_ifc_entities", { 318 | "entity_type": entity_type, 319 | "limit": limit, 320 | "selected_only": selected_only 321 | }) 322 | 323 | # Return the formatted JSON of the results 324 | return json.dumps(result, indent=2) 325 | except Exception as e: 326 | logger.error(f"Error listing IFC entities: {str(e)}") 327 | return f"Error listing IFC entities: {str(e)}" 328 | 329 | # Modify the existing get_ifc_properties function to accept a selected_only parameter 330 | @mcp.tool() 331 | def get_ifc_properties(global_id: str | None = None, selected_only: bool = False) -> str: 332 | """ 333 | Get properties of IFC entities. Can be used to get properties of a specific entity by GlobalId, 334 | or to get properties of all currently selected objects in Blender. 335 | 336 | Args: 337 | global_id: GlobalId of a specific IFC entity (optional if selected_only is True) 338 | selected_only: If True, return properties for all selected objects instead of a specific entity 339 | 340 | Returns: 341 | A JSON-formatted string with entity information and properties 342 | """ 343 | try: 344 | blender = get_blender_connection() 345 | 346 | # Validate parameters 347 | if not global_id and not selected_only: 348 | return json.dumps({"error": "Either global_id or selected_only must be specified"}, indent=2) 349 | 350 | result = blender.send_command("get_ifc_properties", { 351 | "global_id": global_id, 352 | "selected_only": selected_only 353 | }) 354 | 355 | # Return the formatted JSON of the results 356 | return json.dumps(result, indent=2) 357 | except Exception as e: 358 | logger.error(f"Error getting IFC properties: {str(e)}") 359 | return f"Error getting IFC properties: {str(e)}" 360 | 361 | @mcp.tool() 362 | def get_ifc_spatial_structure() -> str: 363 | """ 364 | Get the spatial structure of the IFC model (site, building, storey, space hierarchy). 365 | 366 | Returns: 367 | A JSON-formatted string representing the hierarchical structure of the IFC model 368 | """ 369 | try: 370 | blender = get_blender_connection() 371 | result = blender.send_command("get_ifc_spatial_structure") 372 | 373 | # Return the formatted JSON of the results 374 | return json.dumps(result, indent=2) 375 | except Exception as e: 376 | logger.error(f"Error getting IFC spatial structure: {str(e)}") 377 | return f"Error getting IFC spatial structure: {str(e)}" 378 | 379 | @mcp.tool() 380 | def get_ifc_total_structure() -> str: 381 | """ 382 | Get the complete IFC structure including spatial hierarchy and all building elements. 383 | 384 | This function extends the basic spatial structure to include building elements like walls, 385 | doors, windows, columns, beams, etc. that are contained within each spatial element. 386 | It provides a comprehensive view of how the building is organized both spatially and 387 | in terms of its physical components. 388 | 389 | Returns: 390 | A JSON-formatted string representing the complete hierarchical structure of the IFC model 391 | including spatial elements and their contained building elements, plus summary statistics 392 | """ 393 | try: 394 | blender = get_blender_connection() 395 | result = blender.send_command("get_ifc_total_structure") 396 | 397 | # Return the formatted JSON of the results 398 | return json.dumps(result, indent=2) 399 | except Exception as e: 400 | logger.error(f"Error getting IFC total structure: {str(e)}") 401 | return f"Error getting IFC total structure: {str(e)}" 402 | 403 | @mcp.tool() 404 | def get_ifc_relationships(global_id: str) -> str: 405 | """ 406 | Get all relationships for a specific IFC entity. 407 | 408 | Args: 409 | global_id: GlobalId of the IFC entity 410 | 411 | Returns: 412 | A JSON-formatted string with all relationships the entity participates in 413 | """ 414 | try: 415 | blender = get_blender_connection() 416 | result = blender.send_command("get_ifc_relationships", { 417 | "global_id": global_id 418 | }) 419 | 420 | # Return the formatted JSON of the results 421 | return json.dumps(result, indent=2) 422 | except Exception as e: 423 | logger.error(f"Error getting IFC relationships: {str(e)}") 424 | return f"Error getting IFC relationships: {str(e)}" 425 | 426 | @mcp.tool() 427 | def export_ifc_data( 428 | entity_type: str | None = None, 429 | level_name: str | None = None, 430 | output_format: str = "csv", 431 | ctx: Context | None = None 432 | ) -> str: 433 | """ 434 | Export IFC data to a file in JSON or CSV format. 435 | 436 | This tool extracts IFC data and creates a structured export file. You can filter 437 | by entity type and/or building level, and choose the output format. 438 | 439 | Args: 440 | entity_type: Type of IFC entity to export (e.g., "IfcWall") - leave empty for all entities 441 | level_name: Name of the building level to filter by (e.g., "Level 1") - leave empty for all levels 442 | output_format: "json" or "csv" format for the output file 443 | 444 | Returns: 445 | Confirmation message with the export file path or an error message 446 | """ 447 | try: 448 | # Get Blender connection 449 | blender = get_blender_connection() 450 | 451 | # Validate output format 452 | if output_format not in ["json", "csv"]: 453 | return "Error: output_format must be 'json' or 'csv'" 454 | 455 | # Execute the export code in Blender 456 | result = blender.send_command("export_ifc_data", { 457 | "entity_type": entity_type, 458 | "level_name": level_name, 459 | "output_format": output_format 460 | }) 461 | 462 | # Check for errors from Blender 463 | if isinstance(result, dict) and "error" in result: 464 | return f"Error: {result['error']}" 465 | 466 | # Return the result with export summary 467 | # return result 468 | return json.dumps(result, indent=2) 469 | 470 | except Exception as e: 471 | logger.error(f"Error exporting IFC data: {str(e)}") 472 | return f"Error exporting IFC data: {str(e)}" 473 | 474 | @mcp.tool() 475 | def place_ifc_object( 476 | type_name: str, 477 | x: float, 478 | y: float, 479 | z: float, 480 | rotation: float = 0.0, 481 | ctx: Context| None = None 482 | ) -> str: 483 | """ 484 | Place an IFC object at a specified location with optional rotation. 485 | 486 | This tool allows you to create and position IFC elements in the model. 487 | The object is placed using the specified IFC type and positioned 488 | at the given coordinates with optional rotation around the Z axis. 489 | 490 | Args: 491 | type_name: Name of the IFC element type to place (must exist in the model) 492 | x: X-coordinate in model space 493 | y: Y-coordinate in model space 494 | z: Z-coordinate in model space 495 | rotation: Rotation angle in degrees around the Z axis (default: 0) 496 | 497 | Returns: 498 | A message with the result of the placement operation 499 | """ 500 | try: 501 | # Get Blender connection 502 | blender = get_blender_connection() 503 | 504 | # Send command to place the object 505 | result = blender.send_command("place_ifc_object", { 506 | "type_name": type_name, 507 | "location": [x, y, z], 508 | "rotation": rotation 509 | }) 510 | 511 | # Check for errors 512 | if isinstance(result, dict) and "error" in result: 513 | return f"Error placing object: {result['error']}" 514 | 515 | # Format success message 516 | if isinstance(result, dict) and result.get("success"): 517 | return (f"Successfully placed '{type_name}' object at ({x}, {y}, {z}) " 518 | f"with {rotation}° rotation.\nObject name: {result.get('blender_name')}, " 519 | f"Global ID: {result.get('global_id')}") 520 | 521 | # Return the raw result as string if it's not a success or error dict 522 | return f"Placement result: {json.dumps(result, indent=2)}" 523 | 524 | except Exception as e: 525 | logger.error(f"Error placing IFC object: {str(e)}") 526 | return f"Error placing IFC object: {str(e)}" 527 | 528 | @mcp.tool() 529 | def get_user_view() -> Image: 530 | """ 531 | Capture and return the current Blender viewport as an image. 532 | Shows what the user is currently seeing in Blender. 533 | 534 | Focus mostly on the 3D viewport. Use the UI to assist in your understanding of the scene but only refer to it if specifically prompted. 535 | 536 | Args: 537 | max_dimension: Maximum dimension (width or height) in pixels for the returned image 538 | compression_quality: Image compression quality (1-100, higher is better quality but larger) 539 | 540 | Returns: 541 | An image of the current Blender viewport 542 | """ 543 | max_dimension = 800 544 | compression_quality = 85 545 | 546 | # Use PIL to compress the image 547 | from PIL import Image as PILImage 548 | import io 549 | 550 | try: 551 | # Get the global connection 552 | blender = get_blender_connection() 553 | 554 | # Request current view 555 | result = blender.send_command("get_current_view") 556 | 557 | if "error" in result: 558 | # logger.error(f"Error getting view from Blender: {result.get('error')}") 559 | raise Exception(f"Error getting current view: {result.get('error')}") 560 | 561 | # Extract image information 562 | if "data" not in result or "width" not in result or "height" not in result: 563 | # logger.error("Incomplete image data returned from Blender") 564 | raise Exception("Incomplete image data returned from Blender") 565 | 566 | # Decode the base64 image data 567 | image_data = base64.b64decode(result["data"]) 568 | original_width = result["width"] 569 | original_height = result["height"] 570 | original_format = result.get("format", "png") 571 | 572 | # Compression is only needed if the image is large 573 | if original_width > 800 or original_height > 800 or len(image_data) > 1000000: 574 | # logger.info(f"Compressing image (original size: {len(image_data)} bytes)") 575 | 576 | # Open image from binary data 577 | img = PILImage.open(io.BytesIO(image_data)) 578 | 579 | # Resize if needed 580 | if original_width > max_dimension or original_height > max_dimension: 581 | # Calculate new dimensions maintaining aspect ratio 582 | if original_width > original_height: 583 | new_width = max_dimension 584 | new_height = int(original_height * (max_dimension / original_width)) 585 | else: 586 | new_height = max_dimension 587 | new_width = int(original_width * (max_dimension / original_height)) 588 | 589 | # Resize using high-quality resampling 590 | img = img.resize((new_width, new_height), PILImage.Resampling.LANCZOS) 591 | 592 | # Convert to RGB if needed 593 | if img.mode == 'RGBA': 594 | img = img.convert('RGB') 595 | 596 | # Save as JPEG with compression 597 | output = io.BytesIO() 598 | img.save(output, format='JPEG', quality=compression_quality, optimize=True) 599 | compressed_data = output.getvalue() 600 | 601 | # logger.info(f"Image compressed from {len(image_data)} to {len(compressed_data)} bytes") 602 | 603 | # Return compressed image 604 | return Image(data=compressed_data, format="jpeg") 605 | else: 606 | # Image is small enough, return as-is 607 | return Image(data=image_data, format=original_format) 608 | 609 | except Exception as e: 610 | # logger.error(f"Error processing viewport image: {str(e)}") 611 | raise Exception(f"Error processing viewport image: {str(e)}") 612 | 613 | @mcp.tool() 614 | def get_ifc_quantities() -> str: 615 | """ 616 | Extract and get basic qtos about the IFC project. 617 | 618 | Returns: 619 | A JSON-formatted string with project quantities information 620 | """ 621 | try: 622 | blender = get_blender_connection() 623 | result = blender.send_command("get_ifc_quantities") 624 | 625 | # Return the formatted JSON of the results 626 | return json.dumps(result, indent=2) 627 | except Exception as e: 628 | logger.error(f"Error getting IFC project quantities: {str(e)}") 629 | return f"Error getting IFC project quantities: {str(e)}" 630 | 631 | @mcp.tool() 632 | def export_bc3_budget(language: str = 'es') -> str: 633 | """ 634 | Export a BC3 budget file (FIEBDC-3/2016) based on the IFC model loaded in Blender. 635 | 636 | This tool creates a complete construction budget in BC3 format by: 637 | 1. Extracting the complete IFC spatial structure (Project → Site → Building → Storey) 638 | 2. Extracting IFC quantities and measurements for all building elements 639 | 3. Converting to BC3 hierarchical format with IFC2BC3Converter: 640 | - Generates budget chapters from IFC spatial hierarchy 641 | - Groups building elements by type and categories defined in external JSON 642 | - Assigns unit prices from language-specific JSON database 643 | - Creates detailed measurements sorted alphabetically 644 | 4. Exports to BC3 file with windows-1252 encoding 645 | 646 | Features: 647 | - Multi-language support (Spanish/English) for descriptions and labels 648 | - Automatic element categorization using external JSON configuration 649 | - Optimized conversion with O(1) lookups and batch operations 650 | - Detailed measurements with dimensions (units, length, width, height) 651 | - Full FIEBDC-3/2016 format compliance 652 | 653 | Configuration files (in resources/bc3_helper_files/): 654 | - precios_unitarios.json / unit_prices.json: Unit prices per IFC type 655 | - spatial_labels_es.json / spatial_labels_en.json: Spatial element translations 656 | - element_categories.json: IFC type to category mappings 657 | 658 | Args: 659 | language: Language for the budget file ('es' for Spanish, 'en' for English). Default is 'es'. 660 | 661 | Returns: 662 | A confirmation message with the path to the generated BC3 file in the exports/ folder. 663 | """ 664 | try: 665 | # Get IFC data 666 | logger.info("Getting IFC data...") 667 | ifc_total_structure = get_ifc_total_structure() 668 | ifc_quantities = get_ifc_quantities() 669 | 670 | # Validate that we got valid JSON responses 671 | # If there's an error, these functions return error strings starting with "Error" 672 | if isinstance(ifc_total_structure, str) and ifc_total_structure.startswith("Error"): 673 | return f"Failed to get IFC structure: {ifc_total_structure}" 674 | 675 | if isinstance(ifc_quantities, str) and ifc_quantities.startswith("Error"): 676 | return f"Failed to get IFC quantities: {ifc_quantities}" 677 | 678 | # Try to parse the JSON to ensure it's valid 679 | try: 680 | structure_data = json.loads(ifc_total_structure) if isinstance(ifc_total_structure, str) else ifc_total_structure 681 | quantities_data = json.loads(ifc_quantities) if isinstance(ifc_quantities, str) else ifc_quantities 682 | except json.JSONDecodeError as e: 683 | return f"Invalid JSON data received from Blender. Structure error: {str(e)}" 684 | 685 | converter = IFC2BC3Converter(structure_data, quantities_data, language=language) 686 | output_path = converter.export() 687 | 688 | return f"BC3 file successfully created at: {output_path}" 689 | 690 | except Exception as e: 691 | logger.error(f"Error creating BC3 budget: {str(e)}") 692 | return f"Error creating BC3 budget: {str(e)}" 693 | 694 | # WIP, not ready to be implemented: 695 | # @mcp.tool() 696 | # def create_plan_view(height_offset: float = 0.5, view_type: str = "top", 697 | # resolution_x: int = 400, resolution_y: int = 400, 698 | # output_path: str = None) -> Image: 699 | # """ 700 | # Create a plan view (top-down view) at the specified height above the first building story. 701 | 702 | # Args: 703 | # height_offset: Height in meters above the building story (default 0.5m) 704 | # view_type: Type of view - "top", "front", "right", "left" (note: only "top" is fully implemented) 705 | # resolution_x: Horizontal resolution of the render in pixels - Keep it small, max 800 x 800, recomended 400 x 400 706 | # resolution_y: Vertical resolution of the render in pixels 707 | # output_path: Optional path to save the rendered image 708 | 709 | # Returns: 710 | # A rendered image showing the plan view of the model 711 | # """ 712 | # try: 713 | # # Get the global connection 714 | # blender = get_blender_connection() 715 | 716 | # # Request an orthographic render 717 | # result = blender.send_command("create_orthographic_render", { 718 | # "view_type": view_type, 719 | # "height_offset": height_offset, 720 | # "resolution_x": resolution_x, 721 | # "resolution_y": resolution_y, 722 | # "output_path": output_path # Can be None to use a temporary file 723 | # }) 724 | 725 | # if "error" in result: 726 | # raise Exception(f"Error creating plan view: {result.get('error', 'Unknown error')}") 727 | 728 | # if "data" not in result: 729 | # raise Exception("No image data returned from Blender") 730 | 731 | # # Decode the base64 image data 732 | # image_data = base64.b64decode(result["data"]) 733 | 734 | # # Return as an Image object 735 | # return Image(data=image_data, format="png") 736 | # except Exception as e: 737 | # logger.error(f"Error creating plan view: {str(e)}") 738 | # raise Exception(f"Error creating plan view: {str(e)}") 739 | 740 | 741 | @mcp.tool() 742 | def export_drawing_png( 743 | height_offset: float = 0.5, 744 | view_type: str = "top", 745 | resolution_x: int = 1920, 746 | resolution_y: int = 1080, 747 | storey_name: str | None = None, 748 | output_path: str | None = None 749 | ) -> dict: 750 | """Export drawings as PNG images with custom resolution. 751 | 752 | Creates a drawing, with the view type specified, of the IFC building at the specified 753 | height above the floor level. Supports custom resolution for high-quality architectural drawings. 754 | 755 | Args: 756 | height_offset: Height in meters above the storey level for the camera position (default 0.5m) 757 | view_type: Type of view - "top" for plan view, "front", "right" and "left" for elevation views, and "isometric" for 3D view 758 | resolution_x: Horizontal resolution in pixels (default 1920, max recommended 4096) 759 | resolution_y: Vertical resolution in pixels (default 1080, max recommended 4096) 760 | storey_name: Specific storey name to add to the file name (if None, prints default in the file name) 761 | output_path: Optional file path to save the PNG (if None, returns as base64 image) 762 | 763 | Returns: 764 | metadata and the path of the file image of the drawing at the specified resolution 765 | """ 766 | try: 767 | # Validate resolution limits for performance 768 | if resolution_x > 4096 or resolution_y > 4096: 769 | raise Exception("Resolution too high. Maximum recommended: 4096x4096 pixels") 770 | 771 | if resolution_x < 100 or resolution_y < 100: 772 | raise Exception("Resolution too low. Minimum: 100x100 pixels") 773 | 774 | # Get the global connection 775 | blender = get_blender_connection() 776 | 777 | # Request drawing render 778 | result = blender.send_command("export_drawing_png", { 779 | "view_type": view_type, 780 | "height_offset": height_offset, 781 | "resolution_x": resolution_x, 782 | "resolution_y": resolution_y, 783 | "storey_name": storey_name, 784 | "output_path": output_path 785 | }) 786 | 787 | if "error" in result: 788 | raise Exception(f"Error creating {view_type} drawing: {result.get('error', 'Unknown error')}") 789 | 790 | if "data" not in result: 791 | raise Exception("No image data returned from Blender") 792 | 793 | # Decode the base64 image data 794 | image_data = base64.b64decode(result["data"]) 795 | 796 | # Ensure output path exists 797 | if not output_path: 798 | os.makedirs("./exports/drawings", exist_ok=True) 799 | # Generate filename based on view type 800 | view_name = { 801 | "top": "plan_view", 802 | "front": "front_elevation", 803 | "right": "right_elevation", 804 | "left": "left_elevation", 805 | "isometric": "isometric_view" 806 | }.get(view_type, view_type) 807 | filename = f"{view_name}_{storey_name or 'default'}.png" 808 | output_path = os.path.join("./exports/drawings", filename) 809 | 810 | # Save to file 811 | with open(output_path, "wb") as f: 812 | f.write(image_data) 813 | 814 | # Return only metadata 815 | return { 816 | "status": "success", 817 | "file_path": os.path.abspath(output_path), 818 | # Opcional: si tienes un servidor de archivos, podrías devolver también una URL 819 | # "url": f"http://localhost:8000/files/{filename}" 820 | } 821 | 822 | except Exception as e: 823 | logger.error(f"Error exporting drawing: {str(e)}") 824 | return { "status": "error", "message": str(e) } 825 | 826 | @mcp.tool() 827 | def get_ifc_georeferencing_info(include_contexts: bool = False) -> str: 828 | """ 829 | Checks whether the IFC currently opened in Bonsai/BlenderBIM is georeferenced 830 | and returns the key georeferencing information. 831 | 832 | Parameters 833 | ---------- 834 | include_contexts : bool 835 | If True, adds a breakdown of the RepresentationContexts and operations. 836 | 837 | 838 | Returns 839 | -------- 840 | str (JSON pretty-printed) 841 | { 842 | "georeferenced": true|false, 843 | "crs": { 844 | "name": str|null, 845 | "geodetic_datum": str|null, 846 | "vertical_datum": str|null, 847 | "map_unit": str|null 848 | }, 849 | "map_conversion": { 850 | "eastings": float|null, 851 | "northings": float|null, 852 | "orthogonal_height": float|null, 853 | "scale": float|null, 854 | "x_axis_abscissa": float|null, 855 | "x_axis_ordinate": float|null 856 | }, 857 | "world_coordinate_system": { 858 | "origin": [x, y, z]|null 859 | }, 860 | "true_north": { 861 | "direction_ratios": [x, y]|null 862 | }, 863 | "site": { 864 | "local_placement_origin": [x, y, z]|null, 865 | "ref_latitude": [deg, min, sec, millionth]|null, 866 | "ref_longitude": [deg, min, sec, millionth]|null, 867 | "ref_elevation": float|null 868 | }, 869 | "contexts": [...], # only if include_contexts = true 870 | "warnings": [ ... ] # Informational message 871 | } 872 | 873 | Notes 874 | ----- 875 | - This tool acts as a wrapper: it sends the "get_ifc_georeferencing_info" 876 | command to the Blender add-on. The add-on must implement that logic 877 | (reading IfcProject/IfcGeometricRepresentationContext, IfcMapConversion, 878 | TargetCRS, IfcSite.RefLatitude/RefLongitude/RefElevation, etc.). 879 | - It always returns a JSON string with indentation for easier reading. 880 | """ 881 | blender = get_blender_connection() 882 | params = { 883 | "include_contexts": bool(include_contexts) 884 | } 885 | 886 | try: 887 | result = blender.send_command("get_ifc_georeferencing_info", params) 888 | # Ensures that the result is serializable and easy to read 889 | return json.dumps(result, ensure_ascii=False, indent=2) 890 | except Exception as e: 891 | logger.exception("get_ifc_georeferencing_info error") 892 | return json.dumps( 893 | { 894 | "georeferenced": False, 895 | "error": "Unable to retrieve georeferencing information from the IFC model.", 896 | "details": str(e) 897 | }, 898 | ensure_ascii=False, 899 | indent=2 900 | ) 901 | 902 | @mcp.tool() 903 | def georeference_ifc_model( 904 | crs_mode: str, 905 | epsg: int = None, 906 | crs_name: str = None, 907 | geodetic_datum: str = None, 908 | map_projection: str = None, 909 | map_zone: str = None, 910 | eastings: float = None, 911 | northings: float = None, 912 | orthogonal_height: float = 0.0, 913 | scale: float = 1.0, 914 | x_axis_abscissa: float = None, 915 | x_axis_ordinate: float = None, 916 | true_north_azimuth_deg: float = None, 917 | context_filter: str = "Model", 918 | context_index: int = None, 919 | site_ref_latitude: list = None, # [deg, min, sec, millionth] 920 | site_ref_longitude: list = None, # [deg, min, sec, millionth] 921 | site_ref_elevation: float = None, 922 | site_ref_latitude_dd: float = None, # Decimal degrees (optional) 923 | site_ref_longitude_dd: float = None, # Decimal degrees (optional) 924 | overwrite: bool = False, 925 | dry_run: bool = False, 926 | write_path: str = None, 927 | ) -> str: 928 | """ 929 | Georeferences the IFC currently opened in Bonsai/BlenderBIM by creating or 930 | updating IfcProjectedCRS and IfcMapConversion. Optionally updates IfcSite 931 | and writes the file to disk. 932 | """ 933 | import json 934 | blender = get_blender_connection() 935 | 936 | # Build params excluding None values to keep the payload clean 937 | params = { 938 | "crs_mode": crs_mode, 939 | "epsg": epsg, 940 | "crs_name": crs_name, 941 | "geodetic_datum": geodetic_datum, 942 | "map_projection": map_projection, 943 | "map_zone": map_zone, 944 | "eastings": eastings, 945 | "northings": northings, 946 | "orthogonal_height": orthogonal_height, 947 | "scale": scale, 948 | "x_axis_abscissa": x_axis_abscissa, 949 | "x_axis_ordinate": x_axis_ordinate, 950 | "true_north_azimuth_deg": true_north_azimuth_deg, 951 | "context_filter": context_filter, 952 | "context_index": context_index, 953 | "site_ref_latitude": site_ref_latitude, 954 | "site_ref_longitude": site_ref_longitude, 955 | "site_ref_elevation": site_ref_elevation, 956 | "site_ref_latitude_dd": site_ref_latitude_dd, 957 | "site_ref_longitude_dd": site_ref_longitude_dd, 958 | "overwrite": overwrite, 959 | "dry_run": dry_run, 960 | "write_path": write_path, 961 | } 962 | params = {k: v for k, v in params.items() if v is not None} 963 | 964 | try: 965 | result = blender.send_command("georeference_ifc_model", params) 966 | return json.dumps(result, ensure_ascii=False, indent=2) 967 | except Exception as e: 968 | logger.exception("georeference_ifc_model error") 969 | return json.dumps( 970 | {"success": False, "error": "Could not georeference the model.", "details": str(e)}, 971 | ensure_ascii=False, 972 | indent=2, 973 | ) 974 | 975 | @mcp.tool() 976 | def generate_ids( 977 | title: str, 978 | specs: Union[List[dict], str], # accepts a list of dicts or a JSON string 979 | description: str = "", 980 | author: str = "", 981 | ids_version: Union[str, float] = "", # IDS version (Not IFC version) 982 | purpose: str = "", 983 | milestone: str = "", 984 | date_iso: str = None, 985 | output_path: str = None, 986 | ) -> str: 987 | """ 988 | Creates an .ids file in Blender/Bonsai by calling the add-on handler 'generate_ids'. 989 | 990 | Parameters: 991 | - title (str): Title of the IDS. 992 | - specs (list | JSON str): List of 'specs' containing 'applicability' and 'requirements'. 993 | Each facet is a dict with at least a 'type' field ("Entity", "Attribute", "Property", 994 | "Material", "Classification", "PartOf") and its corresponding attributes. 995 | - description, author, ids_version, date_iso, purpose, milestone: IDS metadata fields. 996 | - output_path (str): Full path to the .ids file to be created. If omitted, the add-on will generate a default name. 997 | 998 | Returns: 999 | - JSON (str) with the handler result: {"ok": bool, "output_path": "...", "message": "..."} 1000 | or {"ok": False, "error": "..."} 1001 | """ 1002 | 1003 | blender = get_blender_connection() 1004 | 1005 | # Allow 'specs' to be received as JSON text (convenient when the client builds it as a string) 1006 | if isinstance(specs, str): 1007 | try: 1008 | specs = json.loads(specs) 1009 | except Exception as e: 1010 | return json.dumps( 1011 | {"ok": False, "error": "Argument 'specs' is not a valid JSON", "details": str(e)}, 1012 | ensure_ascii=False, indent=2 1013 | ) 1014 | 1015 | # Basic validations to avoid sending garbage to the add-on 1016 | if not isinstance(title, str) or not title.strip(): 1017 | return json.dumps({"ok": False, "error": "Empty or invalid 'title' parameter."}, 1018 | ensure_ascii=False, indent=2) 1019 | if not isinstance(specs, list) or not specs: 1020 | return json.dumps({"ok": False, "error": "You must provide at least one 'spec' in 'specs'."}, 1021 | ensure_ascii=False, indent=2) 1022 | 1023 | # Safe coercion of ids_version to str 1024 | if ids_version is not None and not isinstance(ids_version, str): 1025 | ids_version = str(ids_version) 1026 | 1027 | params: dict[str, Any] = { 1028 | "title": title, 1029 | "specs": specs, 1030 | "description": description, 1031 | "author": author, 1032 | "ids_version": ids_version, # ← the handler maps it to the 'version' field of the IDS 1033 | "date_iso": date_iso, 1034 | "output_path": output_path, 1035 | "purpose": purpose, 1036 | "milestone": milestone, 1037 | } 1038 | 1039 | # Cleanup: remove keys with None values to keep the payload clean 1040 | params = {k: v for k, v in params.items() if v is not None} 1041 | 1042 | try: 1043 | # Assignment name must match EXACTLY the one in addon.py 1044 | result = blender.send_command("generate_ids", params) 1045 | # Returns JSON 1046 | return json.dumps(result, ensure_ascii=False, indent=2) 1047 | except Exception as e: 1048 | return json.dumps({"ok": False, "error": "Fallo al crear IDS", "details": str(e)}, 1049 | ensure_ascii=False, indent=2) 1050 | 1051 | 1052 | # ------------------------------- 1053 | # MCP RESOURCES 1054 | # ------------------------------- 1055 | 1056 | # Base path of the resource files 1057 | BASE_PATH = Path("./resources") 1058 | 1059 | @mcp.resource("file://table_of_contents.json") 1060 | def formulas_rp() -> str: 1061 | """Read the content of table_of_contents.json file""" 1062 | file_path = BASE_PATH / "table_of_contents.json" 1063 | try: 1064 | with open(file_path, 'r', encoding='utf-8') as f: 1065 | return f.read() 1066 | except FileNotFoundError: 1067 | return f"Error: File not found {file_path}" 1068 | except Exception as e: 1069 | return f"Error reading file: {str(e)}" 1070 | 1071 | 1072 | # ------------------------------- 1073 | # MCP PROMPTS 1074 | # ------------------------------- 1075 | 1076 | @mcp.prompt("Technical_building_report") 1077 | def technical_building_report(project_name: str, project_location: str, language: str = "english") -> str: 1078 | """ 1079 | Generate a comprehensive technical building report based on an IFC model loaded in Blender. 1080 | 1081 | Args: 1082 | project_name: Name of the project/building 1083 | project_location: Building location (city, address) 1084 | language: Report language - "english", "spanish", "french", "german", "italian", "portuguese" 1085 | 1086 | Returns: 1087 | Structured technical report following basic project standards in the selected language. 1088 | """ 1089 | 1090 | # Language-specific instructions 1091 | language_instructions = { 1092 | "english": { 1093 | "role": "You are a technical architect specialized in creating technical reports for basic building projects.", 1094 | "objective": f"Your objective is to generate a comprehensive technical report for the building \"{project_name}\" located in \"{project_location}\", using data from the IFC model loaded in Blender.", 1095 | "workflow_title": "## MANDATORY WORKFLOW:", 1096 | "report_language": "Write the entire report in English." 1097 | }, 1098 | "spanish": { 1099 | "role": "Eres un arquitecto técnico especializado en la creación de memorias técnicas de proyectos básicos de edificación.", 1100 | "objective": f"Tu objetivo es generar una memoria técnica completa del edificio \"{project_name}\" localizado en \"{project_location}\", utilizando los datos del modelo IFC cargado en Blender.", 1101 | "workflow_title": "## FLUJO DE TRABAJO OBLIGATORIO:", 1102 | "report_language": "Redacta todo el informe en español." 1103 | }, 1104 | "french": { 1105 | "role": "Vous êtes un architecte technique spécialisé dans la création de rapports techniques pour les projets de bâtiment de base.", 1106 | "objective": f"Votre objectif est de générer un rapport technique complet pour le bâtiment \"{project_name}\" situé à \"{project_location}\", en utilisant les données du modèle IFC chargé dans Blender.", 1107 | "workflow_title": "## FLUX DE TRAVAIL OBLIGATOIRE:", 1108 | "report_language": "Rédigez tout le rapport en français." 1109 | }, 1110 | "german": { 1111 | "role": "Sie sind ein technischer Architekt, der sich auf die Erstellung technischer Berichte für grundlegende Bauprojekte spezialisiert hat.", 1112 | "objective": f"Ihr Ziel ist es, einen umfassenden technischen Bericht für das Gebäude \"{project_name}\" in \"{project_location}\" zu erstellen, unter Verwendung der Daten aus dem in Blender geladenen IFC-Modell.", 1113 | "workflow_title": "## OBLIGATORISCHER ARBEITSABLAUF:", 1114 | "report_language": "Verfassen Sie den gesamten Bericht auf Deutsch." 1115 | }, 1116 | "italian": { 1117 | "role": "Sei un architetto tecnico specializzato nella creazione di relazioni tecniche per progetti edilizi di base.", 1118 | "objective": f"Il tuo obiettivo è generare una relazione tecnica completa per l'edificio \"{project_name}\" situato a \"{project_location}\", utilizzando i dati del modello IFC caricato in Blender.", 1119 | "workflow_title": "## FLUSSO DI LAVORO OBBLIGATORIO:", 1120 | "report_language": "Scrivi tutto il rapporto in italiano." 1121 | }, 1122 | "portuguese": { 1123 | "role": "Você é um arquiteto técnico especializado na criação de relatórios técnicos para projetos básicos de construção.", 1124 | "objective": f"Seu objetivo é gerar um relatório técnico abrangente para o edifício \"{project_name}\" localizado em \"{project_location}\", usando dados do modelo IFC carregado no Blender.", 1125 | "workflow_title": "## FLUXO DE TRABALHO OBRIGATÓRIO:", 1126 | "report_language": "Escreva todo o relatório em português." 1127 | } 1128 | } 1129 | 1130 | # Get language instructions (default to English if language not supported) 1131 | lang_config = language_instructions.get(language.lower(), language_instructions["english"]) 1132 | 1133 | return f""" 1134 | {lang_config["role"]} {lang_config["objective"]} 1135 | 1136 | **LANGUAGE REQUIREMENT:** {lang_config["report_language"]} 1137 | 1138 | {lang_config["workflow_title"]} 1139 | 1140 | ### 1. INITIAL IFC MODEL ANALYSIS 1141 | - **Use MCP tool:** `get_ifc_project_info` to get basic project information 1142 | - **Use MCP tool:** `get_ifc_spatial_structure` to understand the building's spatial structure 1143 | - **Use MCP tool:** `get_user_view` to capture a general view of the model 1144 | 1145 | ### 2. OBTAIN TABLE OF CONTENTS 1146 | - **Access MCP resource:** `file://table_of_contents.json` to get the complete technical report structure 1147 | 1148 | ### 3. DETAILED ANALYSIS BY SECTIONS 1149 | 1150 | #### 3.1 For "General Building Data" Section: 1151 | - **Use:** `get_ifc_quantities` to obtain areas and volumes 1152 | - **Use:** `list_ifc_entities` with entity_type="IfcSpace" for spaces 1153 | - **Use:** `list_ifc_entities` with entity_type="IfcBuildingStorey" for floors 1154 | 1155 | #### 3.2 For "Architectural Solution" Section: 1156 | - **Use:** `list_ifc_entities` with entity_type="IfcWall" for walls 1157 | - **Use:** `list_ifc_entities` with entity_type="IfcDoor" for doors 1158 | - **Use:** `list_ifc_entities` with entity_type="IfcWindow" for windows 1159 | - **Use:** `get_user_view` to capture representative views 1160 | 1161 | #### 3.3 For "Construction Systems" Section: 1162 | - **Use:** `list_ifc_entities` with entity_type="IfcBeam" for beams 1163 | - **Use:** `list_ifc_entities` with entity_type="IfcColumn" for columns 1164 | - **Use:** `list_ifc_entities` with entity_type="IfcSlab" for slabs 1165 | - **Use:** `list_ifc_entities` with entity_type="IfcRoof" for roofs 1166 | - **Use:** `get_ifc_properties` to obtain material properties 1167 | 1168 | #### 3.4 For Building Services: 1169 | - **Use:** `list_ifc_entities` with entity_type="IfcPipeSegment" for plumbing 1170 | - **Use:** `list_ifc_entities` with entity_type="IfcCableSegment" for electrical 1171 | - **Use:** `list_ifc_entities` with entity_type="IfcDuctSegment" for HVAC 1172 | 1173 | #### 3.5 For drawings and Graphic Documentation: 1174 | - **Use:** `export_drawing_png` 5 times, using as parameter each time "top", "front", "right", "left" and "isometric", to generate architectural drawings. 1175 | - **Configure:** resolution_x=1920, resolution_y=1080 for adequate quality 1176 | - **Use:** `get_user_view` for complementary 3D views 1177 | 1178 | ### 4. TECHNICAL REPORT STRUCTURE 1179 | 1180 | Organize the document following exactly the structure from the `table_of_contents.json` resource: 1181 | 1182 | **TECHNICAL REPORT – BASIC PROJECT: {project_name}** 1183 | 1184 | **Location:** {project_location} 1185 | 1186 | #### 1. INTRODUCTION 1187 | - Define object and scope based on IFC model data 1188 | - Justify the adopted architectural solution 1189 | 1190 | #### 2. GENERAL BUILDING DATA 1191 | - **Location:** {project_location} 1192 | - **Areas:** Extract from quantities and spaces analysis 1193 | - **Distribution:** Based on IFC spatial structure 1194 | - **Regulations:** Identify applicable regulations according to use and location 1195 | 1196 | #### 3-11. DEVELOPMENT OF ALL SECTIONS 1197 | - Complete each section according to the index, using data extracted from the IFC model 1198 | - Include summary tables of areas, materials and construction elements 1199 | - Generate technical conclusions based on evidence 1200 | 1201 | ### 5. MANDATORY GRAPHIC DOCUMENTATION 1202 | - **2D drawings:**Include the 4 2D drawings generated before in the 3.5 section with the Tool `export_drawing_png` ("top", "front", "right", "left") 1203 | - **3D views:** Include the isometric 3D view generated before in the 3.5 section with the Tool `export_drawing_png` 1204 | - **Organize:** All images in section 11. Annexes 1205 | 1206 | ### 6. TECHNICAL TABLES AND CHARTS 1207 | - **Areas summary table:** Extracted from quantities 1208 | - **Elements listing:** By typologies (walls, columns, beams, etc.) 1209 | - **Material properties:** From IFC properties 1210 | 1211 | ## RESPONSE FORMAT: 1212 | 1213 | ### MARKDOWN STRUCTURE: 1214 | ```markdown 1215 | # TECHNICAL REPORT – BASIC PROJECT 1216 | ## {project_name} 1217 | 1218 | ### Project Data: 1219 | - **Location:** {project_location} 1220 | - **Date:** [current date] 1221 | - **IFC Model:** [model information] 1222 | 1223 | [Complete development of all index sections] 1224 | ``` 1225 | 1226 | ### QUALITY CRITERIA: 1227 | - **Technical precision:** All numerical data extracted directly from IFC model 1228 | - **Completeness:** Cover all index sections mandatory 1229 | - **Professional format:** Markdown tables, structured text, integrated images 1230 | - **Consistency:** Verify data consistency between sections 1231 | 1232 | ## CRITICAL VALIDATIONS: 1233 | 1. **Verify Blender connection:** Confirm IFC model is loaded 1234 | 2. **Complete all sections:** Do not omit any index section 1235 | 3. **Include graphic documentation:** drawings and 3D views mandatory 1236 | 4. **Quantitative data:** Areas, volumes and quantities verified 1237 | 5. **Regulatory consistency:** Applicable regulations according to use and location 1238 | 1239 | **IMPORTANT:** If any MCP tool fails or doesn't return data, document the limitation and indicate that section requires manual completion in executive project phase. 1240 | 1241 | Proceed to generate the technical report following this detailed workflow. 1242 | """ 1243 | 1244 | 1245 | # Main execution 1246 | 1247 | def main(): 1248 | """Run the MCP server""" 1249 | mcp.run() 1250 | 1251 | if __name__ == "__main__": 1252 | main() ```