#
tokens: 33276/50000 13/14 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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()
```
Page 1/2FirstPrevNextLast