addon.py•122 kB
import bpy
import mathutils
import json
import threading
import socket
import time
import requests
import tempfile
import traceback
import os
import shutil
from bpy.props import StringProperty, IntProperty, BoolProperty, EnumProperty
import base64
import bpy
import ifcopenshell
from bonsai.bim.ifc import IfcStore
bl_info = {
"name": "Bonsai MCP",
"author": "JotaDeRodriguez",
"version": (0, 2),
"blender": (3, 0, 0),
"location": "View3D > Sidebar > Bonsai MCP",
"description": "Connect Claude to Blender via MCP. Aimed at IFC projects",
"category": "Interface",
}
class BlenderMCPServer:
def __init__(self, host='localhost', port=9876):
self.host = host
self.port = port
self.running = False
self.socket = None
self.server_thread = None
def start(self):
if self.running:
print("Server is already running")
return
self.running = True
try:
# Create socket
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.socket.bind((self.host, self.port))
self.socket.listen(1)
# Start server thread
self.server_thread = threading.Thread(target=self._server_loop)
self.server_thread.daemon = True
self.server_thread.start()
print(f"BlenderMCP server started on {self.host}:{self.port}")
except Exception as e:
print(f"Failed to start server: {str(e)}")
self.stop()
def stop(self):
self.running = False
# Close socket
if self.socket:
try:
self.socket.close()
except:
pass
self.socket = None
# Wait for thread to finish
if self.server_thread:
try:
if self.server_thread.is_alive():
self.server_thread.join(timeout=1.0)
except:
pass
self.server_thread = None
print("BlenderMCP server stopped")
def _server_loop(self):
"""Main server loop in a separate thread"""
print("Server thread started")
self.socket.settimeout(1.0) # Timeout to allow for stopping
while self.running:
try:
# Accept new connection
try:
client, address = self.socket.accept()
print(f"Connected to client: {address}")
# Handle client in a separate thread
client_thread = threading.Thread(
target=self._handle_client,
args=(client,)
)
client_thread.daemon = True
client_thread.start()
except socket.timeout:
# Just check running condition
continue
except Exception as e:
print(f"Error accepting connection: {str(e)}")
time.sleep(0.5)
except Exception as e:
print(f"Error in server loop: {str(e)}")
if not self.running:
break
time.sleep(0.5)
print("Server thread stopped")
def _handle_client(self, client):
"""Handle connected client"""
print("Client handler started")
client.settimeout(None) # No timeout
buffer = b''
try:
while self.running:
# Receive data
try:
data = client.recv(8192)
if not data:
print("Client disconnected")
break
buffer += data
try:
# Try to parse command
command = json.loads(buffer.decode('utf-8'))
buffer = b''
# Execute command in Blender's main thread
def execute_wrapper():
try:
response = self.execute_command(command)
response_json = json.dumps(response)
try:
client.sendall(response_json.encode('utf-8'))
except:
print("Failed to send response - client disconnected")
except Exception as e:
print(f"Error executing command: {str(e)}")
traceback.print_exc()
try:
error_response = {
"status": "error",
"message": str(e)
}
client.sendall(json.dumps(error_response).encode('utf-8'))
except:
pass
return None
# Schedule execution in main thread
bpy.app.timers.register(execute_wrapper, first_interval=0.0)
except json.JSONDecodeError:
# Incomplete data, wait for more
pass
except Exception as e:
print(f"Error receiving data: {str(e)}")
break
except Exception as e:
print(f"Error in client handler: {str(e)}")
finally:
try:
client.close()
except:
pass
print("Client handler stopped")
def execute_command(self, command):
"""Execute a command in the main Blender thread"""
try:
cmd_type = command.get("type")
params = command.get("params", {})
# Ensure we're in the right context
if cmd_type in ["create_object", "modify_object", "delete_object"]:
override = bpy.context.copy()
override['area'] = [area for area in bpy.context.screen.areas if area.type == 'VIEW_3D'][0]
with bpy.context.temp_override(**override):
return self._execute_command_internal(command)
else:
return self._execute_command_internal(command)
except Exception as e:
print(f"Error executing command: {str(e)}")
traceback.print_exc()
return {"status": "error", "message": str(e)}
def _execute_command_internal(self, command):
"""Internal command execution with proper context"""
cmd_type = command.get("type")
params = command.get("params", {})
# Base handlers that are always available
handlers = {
"execute_code": self.execute_code,
"get_ifc_project_info": self.get_ifc_project_info,
"list_ifc_entities": self.list_ifc_entities,
"get_ifc_properties": self.get_ifc_properties,
"get_ifc_spatial_structure": self.get_ifc_spatial_structure,
"get_ifc_total_structure": self.get_ifc_total_structure,
"get_ifc_relationships": self.get_ifc_relationships,
"get_selected_ifc_entities": self.get_selected_ifc_entities,
"get_current_view": self.get_current_view,
"export_ifc_data": self.export_ifc_data,
"place_ifc_object": self.place_ifc_object,
"get_ifc_quantities": self.get_ifc_quantities,
"export_drawing_png": self.export_drawing_png,
"get_ifc_georeferencing_info": self.get_ifc_georeferencing_info,
"georeference_ifc_model": self.georeference_ifc_model,
"generate_ids": self.generate_ids,
}
handler = handlers.get(cmd_type)
if handler:
try:
print(f"Executing handler for {cmd_type}")
result = handler(**params)
print(f"Handler execution complete")
return {"status": "success", "result": result}
except Exception as e:
print(f"Error in handler: {str(e)}")
traceback.print_exc()
return {"status": "error", "message": str(e)}
else:
return {"status": "error", "message": f"Unknown command type: {cmd_type}"}
def execute_code(self, code):
"""Execute arbitrary Blender Python code"""
# This is powerful but potentially dangerous - use with caution
try:
# Create a local namespace for execution
namespace = {"bpy": bpy}
exec(code, namespace)
return {"executed": True}
except Exception as e:
raise Exception(f"Code execution error: {str(e)}")
@staticmethod
def get_selected_ifc_entities():
"""
Get the IFC entities corresponding to the currently selected Blender objects.
Returns:
List of IFC entities for the selected objects
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Get currently selected objects
selected_objects = bpy.context.selected_objects
if not selected_objects:
return {"selected_count": 0, "message": "No objects selected in Blender"}
# Collect IFC entities from selected objects
selected_entities = []
for obj in selected_objects:
if hasattr(obj, "BIMObjectProperties") and obj.BIMObjectProperties.ifc_definition_id:
entity_id = obj.BIMObjectProperties.ifc_definition_id
entity = file.by_id(entity_id)
if entity:
entity_info = {
"id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}",
"ifc_id": entity.id(),
"type": entity.is_a(),
"name": entity.Name if hasattr(entity, "Name") else None,
"blender_name": obj.name
}
selected_entities.append(entity_info)
return {
"selected_count": len(selected_entities),
"selected_entities": selected_entities
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
### SPECIFIC IFC METHODS ###
@staticmethod
def get_ifc_project_info():
"""
Get basic information about the IFC project.
Returns:
Dictionary with project name, description, and basic metrics
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Get project information
projects = file.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in the model"}
project = projects[0]
# Basic project info
info = {
"id": project.GlobalId,
"name": project.Name if hasattr(project, "Name") else "Unnamed Project",
"description": project.Description if hasattr(project, "Description") else None,
"entity_counts": {}
}
# Count entities by type
entity_types = ["IfcWall", "IfcDoor", "IfcWindow", "IfcSlab", "IfcBeam", "IfcColumn", "IfcSpace", "IfcBuildingStorey"]
for entity_type in entity_types:
entities = file.by_type(entity_type)
info["entity_counts"][entity_type] = len(entities)
return info
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def list_ifc_entities(entity_type=None, limit=50, selected_only=False):
"""
List IFC entities of a specific type.
Parameters:
entity_type: Type of IFC entity to list (e.g., "IfcWall")
limit: Maximum number of entities to return
Returns:
List of entities with basic properties
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# If we're only looking at selected objects
if selected_only:
selected_result = BlenderMCPServer.get_selected_ifc_entities()
# Check for errors
if "error" in selected_result:
return selected_result
# If no objects are selected, return early
if selected_result["selected_count"] == 0:
return selected_result
# If entity_type is specified, filter the selected entities
if entity_type:
filtered_entities = [
entity for entity in selected_result["selected_entities"]
if entity["type"] == entity_type
]
return {
"type": entity_type,
"selected_count": len(filtered_entities),
"entities": filtered_entities[:limit]
}
else:
# Group selected entities by type
entity_types = {}
for entity in selected_result["selected_entities"]:
entity_type = entity["type"]
if entity_type in entity_types:
entity_types[entity_type].append(entity)
else:
entity_types[entity_type] = [entity]
return {
"selected_count": selected_result["selected_count"],
"entity_types": [
{"type": t, "count": len(entities), "entities": entities[:limit]}
for t, entities in entity_types.items()
]
}
# Original functionality for non-selected mode
if not entity_type:
# If no type specified, list available entity types
entity_types = {}
for entity in file.wrapped_data.entities:
entity_type = entity.is_a()
if entity_type in entity_types:
entity_types[entity_type] += 1
else:
entity_types[entity_type] = 1
return {
"available_types": [{"type": k, "count": v} for k, v in entity_types.items()]
}
# Get entities of the specified type
entities = file.by_type(entity_type)
# Prepare the result
result = {
"type": entity_type,
"total_count": len(entities),
"entities": []
}
# Add entity data (limited)
for i, entity in enumerate(entities):
if i >= limit:
break
entity_data = {
"id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}",
"name": entity.Name if hasattr(entity, "Name") else None
}
result["entities"].append(entity_data)
return result
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def get_ifc_properties(global_id=None, selected_only=False):
"""
Get all properties of a specific IFC entity.
Parameters:
global_id: GlobalId of the IFC entity
Returns:
Dictionary with entity information and properties
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# If we're only looking at selected objects
if selected_only:
selected_result = BlenderMCPServer.get_selected_ifc_entities()
# Check for errors
if "error" in selected_result:
return selected_result
# If no objects are selected, return early
if selected_result["selected_count"] == 0:
return selected_result
# Process each selected entity
result = {
"selected_count": selected_result["selected_count"],
"entities": []
}
for entity_info in selected_result["selected_entities"]:
# Find entity by GlobalId
entity = file.by_guid(entity_info["id"])
if not entity:
continue
# Get basic entity info
entity_data = {
"id": entity.GlobalId,
"type": entity.is_a(),
"name": entity.Name if hasattr(entity, "Name") else None,
"description": entity.Description if hasattr(entity, "Description") else None,
"blender_name": entity_info["blender_name"],
"property_sets": {}
}
# Get all property sets
psets = ifcopenshell.util.element.get_psets(entity)
for pset_name, pset_data in psets.items():
entity_data["property_sets"][pset_name] = pset_data
result["entities"].append(entity_data)
return result
# If we're looking at a specific entity
elif global_id:
# Find entity by GlobalId
entity = file.by_guid(global_id)
if not entity:
return {"error": f"No entity found with GlobalId: {global_id}"}
# Get basic entity info
entity_info = {
"id": entity.GlobalId,
"type": entity.is_a(),
"name": entity.Name if hasattr(entity, "Name") else None,
"description": entity.Description if hasattr(entity, "Description") else None,
"property_sets": {}
}
# Get all property sets
psets = ifcopenshell.util.element.get_psets(entity)
for pset_name, pset_data in psets.items():
entity_info["property_sets"][pset_name] = pset_data
return entity_info
else:
return {"error": "Either global_id or selected_only must be specified"}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def get_ifc_spatial_structure():
"""
Get the spatial structure of the IFC model (site, building, storey, space hierarchy).
Returns:
Hierarchical structure of the IFC model's spatial elements
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Start with projects
projects = file.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in the model"}
def get_children(parent):
"""Get immediate children of the given element"""
if hasattr(parent, "IsDecomposedBy"):
rel_aggregates = parent.IsDecomposedBy
children = []
for rel in rel_aggregates:
children.extend(rel.RelatedObjects)
return children
return []
def create_structure(element):
"""Recursively create the structure for an element"""
result = {
"id": element.GlobalId,
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
"children": []
}
for child in get_children(element):
result["children"].append(create_structure(child))
return result
# Create the structure starting from the project
structure = create_structure(projects[0])
return structure
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def get_ifc_total_structure():
"""
Get the complete IFC structure including spatial hierarchy and building elements.
This function extends the spatial structure to include building elements like walls,
doors, windows, etc. that are contained in each spatial element.
Returns:
Complete hierarchical structure with spatial elements and their contained building elements
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Start with projects
projects = file.by_type("IfcProject")
if not projects:
return {"error": "No IfcProject found in the model"}
def get_spatial_children(parent):
"""Get immediate spatial children of the given element"""
if hasattr(parent, "IsDecomposedBy"):
rel_aggregates = parent.IsDecomposedBy
children = []
for rel in rel_aggregates:
children.extend(rel.RelatedObjects)
return children
return []
def get_contained_elements(spatial_element):
"""Get building elements contained in this spatial element"""
contained_elements = []
# Check for IfcRelContainedInSpatialStructure relationships
if hasattr(spatial_element, "ContainsElements"):
for rel in spatial_element.ContainsElements:
for element in rel.RelatedElements:
element_info = {
"id": element.GlobalId,
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
"description": element.Description if hasattr(element, "Description") else None
}
contained_elements.append(element_info)
return contained_elements
def create_total_structure(element):
"""Recursively create the complete structure for an element"""
result = {
"id": element.GlobalId,
"type": element.is_a(),
"name": element.Name if hasattr(element, "Name") else None,
"description": element.Description if hasattr(element, "Description") else None,
"children": [],
"building_elements": []
}
# Add spatial children (other spatial elements)
for child in get_spatial_children(element):
result["children"].append(create_total_structure(child))
# Add contained building elements (walls, doors, windows, etc.)
result["building_elements"] = get_contained_elements(element)
return result
# Create the complete structure starting from the project
total_structure = create_total_structure(projects[0])
return total_structure
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def get_ifc_relationships(global_id):
"""
Get all relationships for a specific IFC entity.
Parameters:
global_id: GlobalId of the IFC entity
Returns:
Dictionary with all relationships the entity participates in
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Find entity by GlobalId
entity = file.by_guid(global_id)
if not entity:
return {"error": f"No entity found with GlobalId: {global_id}"}
# Basic entity info
entity_info = {
"id": entity.GlobalId,
"type": entity.is_a(),
"name": entity.Name if hasattr(entity, "Name") else None,
"relationships": {
"contains": [],
"contained_in": [],
"connects": [],
"connected_by": [],
"defines": [],
"defined_by": []
}
}
# Check if entity contains other elements
if hasattr(entity, "IsDecomposedBy"):
for rel in entity.IsDecomposedBy:
for obj in rel.RelatedObjects:
entity_info["relationships"]["contains"].append({
"id": obj.GlobalId,
"type": obj.is_a(),
"name": obj.Name if hasattr(obj, "Name") else None
})
# Check if entity is contained in other elements
if hasattr(entity, "Decomposes"):
for rel in entity.Decomposes:
rel_obj = rel.RelatingObject
entity_info["relationships"]["contained_in"].append({
"id": rel_obj.GlobalId,
"type": rel_obj.is_a(),
"name": rel_obj.Name if hasattr(rel_obj, "Name") else None
})
# For physical connections (depends on entity type)
if hasattr(entity, "ConnectedTo"):
for rel in entity.ConnectedTo:
for obj in rel.RelatedElement:
entity_info["relationships"]["connects"].append({
"id": obj.GlobalId,
"type": obj.is_a(),
"name": obj.Name if hasattr(obj, "Name") else None,
"connection_type": rel.ConnectionType if hasattr(rel, "ConnectionType") else None
})
if hasattr(entity, "ConnectedFrom"):
for rel in entity.ConnectedFrom:
obj = rel.RelatingElement
entity_info["relationships"]["connected_by"].append({
"id": obj.GlobalId,
"type": obj.is_a(),
"name": obj.Name if hasattr(obj, "Name") else None,
"connection_type": rel.ConnectionType if hasattr(rel, "ConnectionType") else None
})
return entity_info
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def export_ifc_data(entity_type=None, level_name=None, output_format="csv"):
"""Export IFC data to a structured file"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
data_list = []
# Filter objects based on type
if entity_type:
objects = file.by_type(entity_type)
else:
objects = file.by_type("IfcElement")
# Create a data dictionary for each object
for obj in objects:
obj_data = {}
# Get level/storey information
container_level = None
try:
containing_structure = ifcopenshell.util.element.get_container(obj)
if containing_structure and containing_structure.is_a("IfcBuildingStorey"):
container_level = containing_structure.Name
except Exception as e:
pass
# Skip if we're filtering by level and this doesn't match
if level_name and container_level != level_name:
continue
# Basic information
obj_data['ExpressId'] = obj.id()
obj_data['GlobalId'] = obj.GlobalId if hasattr(obj, "GlobalId") else None
obj_data['IfcClass'] = obj.is_a()
obj_data['Name'] = obj.Name if hasattr(obj, "Name") else None
obj_data['Description'] = obj.Description if hasattr(obj, "Description") else None
obj_data['LevelName'] = container_level
# Get predefined type if available
try:
obj_data['PredefinedType'] = ifcopenshell.util.element.get_predefined_type(obj)
except:
obj_data['PredefinedType'] = None
# Get type information
try:
type_obj = ifcopenshell.util.element.get_type(obj)
obj_data['TypeName'] = type_obj.Name if type_obj and hasattr(type_obj, "Name") else None
obj_data['TypeClass'] = type_obj.is_a() if type_obj else None
except:
obj_data['TypeName'] = None
obj_data['TypeClass'] = None
# Get property sets (simplify structure for export)
try:
property_sets = ifcopenshell.util.element.get_psets(obj)
# Flatten property sets for better export compatibility
for pset_name, pset_data in property_sets.items():
for prop_name, prop_value in pset_data.items():
obj_data[f"{pset_name}.{prop_name}"] = prop_value
except Exception as e:
pass
data_list.append(obj_data)
if not data_list:
return "No data found matching the specified criteria"
# Determine output directory - try multiple options to ensure it works in various environments
output_dirs = [
"C:\\Users\\Public\\Documents" if os.name == "nt" else None, # Public Documents
"/usr/share" if os.name != "nt" else None, # Unix share directory
"/tmp", # Unix temp directory
"C:\\Temp" if os.name == "nt" else None, # Windows temp directory
]
output_dir = None
for dir_path in output_dirs:
if dir_path and os.path.exists(dir_path) and os.access(dir_path, os.W_OK):
output_dir = dir_path
break
if not output_dir:
return {"error": "Could not find a writable directory for output"}
# Create filename based on filters
filters = []
if entity_type:
filters.append(entity_type)
if level_name:
filters.append(level_name)
filter_str = "_".join(filters) if filters else "all"
timestamp = time.strftime("%Y%m%d_%H%M%S")
filename = f"ifc_export_{filter_str}_{timestamp}.{output_format}"
filepath = os.path.join(output_dir, filename)
# Export based on format
if output_format == "json":
with open(filepath, 'w') as f:
json.dump(data_list, f, indent=2)
elif output_format == "csv":
import pandas as pd
df = pd.DataFrame(data_list)
df.to_csv(filepath, index=False)
# Summary info for the response
entity_count = len(data_list)
entity_types = set(item['IfcClass'] for item in data_list)
levels = set(item['LevelName'] for item in data_list if item['LevelName'])
return {
"success": True,
"message": f"Data exported successfully to {filepath}",
"filepath": filepath,
"format": output_format,
"summary": {
"entity_count": entity_count,
"entity_types": list(entity_types),
"levels": list(levels)
}
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def place_ifc_object(type_name, location, rotation=None):
"""
Place an IFC object at specified location with optional rotation
Args:
type_name: Name of the IFC element type
location: [x, y, z] list or tuple for position
rotation: Value in degrees for rotation around Z axis (optional)
Returns:
Dictionary with information about the created object
"""
try:
import ifcopenshell
from bonsai.bim.ifc import IfcStore
import math
# Convert location to tuple if it's not already
if isinstance(location, list):
location = tuple(location)
def find_type_by_name(name):
file = IfcStore.get_file()
for element in file.by_type("IfcElementType"):
if element.Name == name:
return element.id()
return None
# Find the type ID
type_id = find_type_by_name(type_name)
if not type_id:
return {"error": f"Type '{type_name}' not found. Please check if this type exists in the model."}
# Store original context
original_context = bpy.context.copy()
# Ensure we're in 3D View context
override = bpy.context.copy()
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
override["area"] = area
override["region"] = area.regions[-1]
break
# Set cursor location
bpy.context.scene.cursor.location = location
# Get properties to set up parameters
props = bpy.context.scene.BIMModelProperties
# Store original rl_mode and set to CURSOR to use cursor's Z position
original_rl_mode = props.rl_mode
props.rl_mode = 'CURSOR'
# Create the object using the override context
with bpy.context.temp_override(**override):
bpy.ops.bim.add_occurrence(relating_type_id=type_id)
# Get the newly created object
obj = bpy.context.active_object
if not obj:
props.rl_mode = original_rl_mode
return {"error": "Failed to create object"}
# Force the Z position explicitly
obj.location.z = location[2]
# Apply rotation if provided
if rotation is not None:
# Convert degrees to radians for Blender's rotation_euler
full_rotation = (0, 0, math.radians(float(rotation)))
obj.rotation_euler = full_rotation
# Sync the changes back to IFC
# Use the appropriate method depending on what's available
if hasattr(bpy.ops.bim, "update_representation"):
bpy.ops.bim.update_representation(obj=obj.name)
# Restore original rl_mode
props.rl_mode = original_rl_mode
# Get the IFC entity for the new object
entity_id = obj.BIMObjectProperties.ifc_definition_id
if entity_id:
file = IfcStore.get_file()
entity = file.by_id(entity_id)
global_id = entity.GlobalId if hasattr(entity, "GlobalId") else None
else:
global_id = None
# Return information about the created object
return {
"success": True,
"blender_name": obj.name,
"global_id": global_id,
"location": list(obj.location),
"rotation": list(obj.rotation_euler),
"type_name": type_name
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
### Ability to see
@staticmethod
def get_current_view():
"""Capture and return the current viewport as an image"""
try:
# Find a 3D View
for area in bpy.context.screen.areas:
if area.type == 'VIEW_3D':
break
else:
return {"error": "No 3D View available"}
# Create temporary file to save the viewport screenshot
temp_file = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
temp_path = temp_file.name
temp_file.close()
# Find appropriate region
for region in area.regions:
if region.type == 'WINDOW':
break
else:
return {"error": "No appropriate region found in 3D View"}
# Use temp_override instead of the old override dictionary
with bpy.context.temp_override(area=area, region=region):
# Save screenshot
bpy.ops.screen.screenshot(filepath=temp_path)
# Read the image data and encode as base64
with open(temp_path, 'rb') as f:
image_data = f.read()
# Clean up
os.unlink(temp_path)
# Return base64 encoded image
return {
"width": area.width,
"height": area.height,
"format": "png",
"data": base64.b64encode(image_data).decode('utf-8')
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def get_ifc_quantities(entity_type=None, selected_only=False):
"""
Calculate and get quantities (m2, m3, etc.) for IFC elements.
Parameters:
entity_type: Type of IFC entity to get quantities for (e.g., "IfcWall", "IfcSlab")
selected_only: If True, only get quantities for selected objects
Returns:
Dictionary with quantities for the specified elements
"""
try:
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Check if BaseQuantities already exist to avoid re-calculating
quantities_exist = False
sample_elements = file.by_type("IfcElement")[:10] if file.by_type("IfcElement") else []
for elem in sample_elements:
psets = ifcopenshell.util.element.get_psets(elem)
if any(qset in psets for qset in ["BaseQuantities", "Qto_WallBaseQuantities",
"Qto_SlabBaseQuantities", "Qto_BeamBaseQuantities"]):
quantities_exist = True
break
# Only calculate quantities if they don't exist yet
if not quantities_exist:
try:
bpy.ops.bim.perform_quantity_take_off()
except Exception as e:
return {"error": f"Failed to calculate quantities: {str(e)}"}
elements_data = []
# If we're only looking at selected objects
if selected_only:
selected_result = BlenderMCPServer.get_selected_ifc_entities()
# Check for errors
if "error" in selected_result:
return selected_result
# If no objects are selected, return early
if selected_result["selected_count"] == 0:
return selected_result
# Process each selected entity
for entity_info in selected_result["selected_entities"]:
# Find entity by GlobalId
entity = file.by_guid(entity_info["id"])
if not entity:
continue
# Filter by type if specified
if entity_type and entity.is_a() != entity_type:
continue
# Extract quantities
element_data = extract_quantities(entity, entity_info["blender_name"])
if element_data:
elements_data.append(element_data)
else:
# Get entities based on type or default to common element types
if entity_type:
entities = file.by_type(entity_type)
else:
# Get common element types that have quantities
entity_types = ["IfcWall", "IfcSlab", "IfcBeam", "IfcColumn", "IfcDoor", "IfcWindow"]
entities = []
for etype in entity_types:
entities.extend(file.by_type(etype))
# Process each entity
for entity in entities:
element_data = extract_quantities(entity)
if element_data:
elements_data.append(element_data)
# Summary statistics
summary = {
"total_elements": len(elements_data),
"element_types": {}
}
# Group by element type for summary
for element in elements_data:
etype = element["type"]
if etype not in summary["element_types"]:
summary["element_types"][etype] = {"count": 0, "total_area": 0, "total_volume": 0}
summary["element_types"][etype]["count"] += 1
if element["quantities"].get("area"):
summary["element_types"][etype]["total_area"] += element["quantities"]["area"]
if element["quantities"].get("volume"):
summary["element_types"][etype]["total_volume"] += element["quantities"]["volume"]
return {
"success": True,
"elements": elements_data,
"summary": summary
}
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def export_drawing_png(view_type="top", height_offset=0.5, resolution_x=1920,
resolution_y=1080, storey_name=None, output_path=None):
"""
Export drawings as PNG images with custom resolution.
Creates 2D and 3D views of IFC building, particularly useful for architectural drawings.
Args:
view_type: "top" for plan view, "front", "right", "left" for elevations, "isometric" for 3D view
height_offset: Height in meters above storey level for camera position
resolution_x: Horizontal resolution in pixels
resolution_y: Vertical resolution in pixels
storey_name: Specific storey name to render (None for all/ground floor)
output_path: File path to save PNG (None for temp file)
Returns:
Dict with base64 encoded image data and metadata
"""
try:
import tempfile
import os
# Validate parameters
if resolution_x > 4096 or resolution_y > 4096:
return {"error": "Resolution too high. Maximum: 4096x4096"}
if resolution_x < 100 or resolution_y < 100:
return {"error": "Resolution too low. Minimum: 100x100"}
# Check if IFC file is loaded
file = IfcStore.get_file()
if file is None:
return {"error": "No IFC file is currently loaded"}
# Store original render settings
scene = bpy.context.scene
original_engine = scene.render.engine
original_res_x = scene.render.resolution_x
original_res_y = scene.render.resolution_y
original_filepath = scene.render.filepath
# Set up render settings for drawing
scene.render.engine = 'BLENDER_WORKBENCH' # Fast, good for architectural drawings
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
scene.render.resolution_percentage = 100
# Store original camera if exists
original_camera = bpy.context.scene.camera
# Create temporary camera for orthographic rendering
bpy.ops.object.camera_add()
camera = bpy.context.object
camera.name = "TempDrawingCamera"
bpy.context.scene.camera = camera
# Set camera to orthographic
camera.data.type = 'ORTHO'
camera.data.ortho_scale = 50 # Adjust based on building size
# Position camera based on view type and storey
if view_type == "top":
# Find building bounds to position camera appropriately
all_objects = [obj for obj in bpy.context.scene.objects
if obj.type == 'MESH' and obj.visible_get()]
if all_objects:
# Calculate bounding box of all visible objects
min_x = min_y = min_z = float('inf')
max_x = max_y = max_z = float('-inf')
for obj in all_objects:
bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
for corner in bbox:
min_x = min(min_x, corner.x)
max_x = max(max_x, corner.x)
min_y = min(min_y, corner.y)
max_y = max(max_y, corner.y)
min_z = min(min_z, corner.z)
max_z = max(max_z, corner.z)
# Position camera above the building
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
# For plan view, position camera above
camera_height = max_z + height_offset
camera.location = (center_x, center_y, camera_height)
camera.rotation_euler = (0, 0, 0) # Look down
# Adjust orthographic scale based on building size
building_width = max(max_x - min_x, max_y - min_y) * 1.2 # Add 20% margin
camera.data.ortho_scale = building_width
else:
# Default position if no objects found
camera.location = (0, 0, 10)
camera.rotation_euler = (0, 0, 0)
elif view_type in ["front", "right", "left"]:
# For elevations, position camera accordingly
# This is a simplified implementation - could be enhanced
all_objects = [obj for obj in bpy.context.scene.objects
if obj.type == 'MESH' and obj.visible_get()]
if all_objects:
# Calculate bounds
min_x = min_y = min_z = float('inf')
max_x = max_y = max_z = float('-inf')
for obj in all_objects:
bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
for corner in bbox:
min_x = min(min_x, corner.x)
max_x = max(max_x, corner.x)
min_y = min(min_y, corner.y)
max_y = max(max_y, corner.y)
min_z = min(min_z, corner.z)
max_z = max(max_z, corner.z)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
center_z = (min_z + max_z) / 2
building_depth = max(max_x - min_x, max_y - min_y) * 2
if view_type == "front":
camera.location = (center_x, center_y - building_depth, center_z)
camera.rotation_euler = (1.5708, 0, 0) # 90 degrees X rotation
elif view_type == "right":
camera.location = (center_x + building_depth, center_y, center_z)
camera.rotation_euler = (1.5708, 0, 1.5708) # Look from right
elif view_type == "left":
camera.location = (center_x - building_depth, center_y, center_z)
camera.rotation_euler = (1.5708, 0, -1.5708) # Look from left
# Adjust scale for elevations
building_height = max_z - min_z
building_width = max(max_x - min_x, max_y - min_y)
camera.data.ortho_scale = max(building_height, building_width) * 1.2
elif view_type == "isometric":
# For isometric view, use perspective camera positioned diagonally
camera.data.type = 'PERSP'
camera.data.lens = 35 # 35mm lens for nice perspective
all_objects = [obj for obj in bpy.context.scene.objects
if obj.type == 'MESH' and obj.visible_get()]
if all_objects:
# Calculate bounds
min_x = min_y = min_z = float('inf')
max_x = max_y = max_z = float('-inf')
for obj in all_objects:
bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box]
for corner in bbox:
min_x = min(min_x, corner.x)
max_x = max(max_x, corner.x)
min_y = min(min_y, corner.y)
max_y = max(max_y, corner.y)
min_z = min(min_z, corner.z)
max_z = max(max_z, corner.z)
center_x = (min_x + max_x) / 2
center_y = (min_y + max_y) / 2
center_z = (min_z + max_z) / 2
# Calculate distance to frame the building nicely
building_size = max(max_x - min_x, max_y - min_y, max_z - min_z)
distance = building_size * 1.2 # Distance multiplier for good framing
# Position camera for isometric view (45° angles)
# Classic isometric position: up and back, looking down at 30°
import math
angle_rad = math.radians(45)
camera_x = center_x + distance * math.cos(angle_rad)
camera_y = center_y - distance * math.sin(angle_rad)
camera_z = center_z + distance * 0.3 # Lower elevation for better facade view
camera.location = (camera_x, camera_y, camera_z)
# Point camera at building center
direction = mathutils.Vector((center_x - camera_x, center_y - camera_y, center_z - camera_z))
camera.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler()
else:
# Default isometric position
camera.location = (15, -15, 10)
camera.rotation_euler = (1.1, 0, 0.785) # ~63°, 0°, ~45°
# Set up output file path
if output_path:
render_path = output_path
else:
temp_dir = tempfile.gettempdir()
render_path = os.path.join(temp_dir, f"drawing_{view_type}_{int(time.time())}.png")
scene.render.filepath = render_path
scene.render.image_settings.file_format = 'PNG'
# Render the image
bpy.ops.render.render(write_still=True)
# Read the rendered image and encode as base64
if os.path.exists(render_path):
with open(render_path, 'rb') as f:
image_data = f.read()
# Clean up temporary file if we created it
if not output_path:
os.remove(render_path)
# Restore original settings
scene.render.engine = original_engine
scene.render.resolution_x = original_res_x
scene.render.resolution_y = original_res_y
scene.render.filepath = original_filepath
bpy.context.scene.camera = original_camera
# Delete temporary camera
bpy.data.objects.remove(camera, do_unlink=True)
# Return base64 encoded image
import base64
return {
"success": True,
"data": base64.b64encode(image_data).decode('utf-8'),
"format": "png",
"resolution": f"{resolution_x}x{resolution_y}",
"view_type": view_type,
"output_path": render_path if output_path else None
}
else:
return {"error": "Failed to create render file"}
except Exception as e:
# Restore settings on error
try:
scene = bpy.context.scene
scene.render.engine = original_engine
scene.render.resolution_x = original_res_x
scene.render.resolution_y = original_res_y
scene.render.filepath = original_filepath
bpy.context.scene.camera = original_camera
# Clean up camera if it exists
if 'camera' in locals() and camera:
bpy.data.objects.remove(camera, do_unlink=True)
except:
pass
import traceback
return {"error": f"Error creating drawing: {str(e)}",
"traceback": traceback.format_exc()}
@staticmethod
def get_ifc_georeferencing_info(include_contexts: bool = False):
"""
Retrieves georeferencing information from the currently opened IFC file (CRS, MapConversion, WCS, TrueNorth, IfcSite).
Args:
include_contexts (bool): If True, adds the breakdown of RepresentationContexts and operations
Returns:
dict: Structure with:
{
"georeferenced": bool,
"crs": {
"name": str|None,
"geodetic_datum": str|None,
"vertical_datum": str|None,
"map_unit": str|None
},
"map_conversion": {
"eastings": float|None,
"northings": float|None,
"orthogonal_height": float|None,
"scale": float|None,
"x_axis_abscissa": float|None,
"x_axis_ordinate": float|None
},
"world_coordinate_system": {"origin": [x,y,z]|None},
"true_north": {"direction_ratios": [x,y]|None},
"site": {
"local_placement_origin": [x,y,z]|None,
"ref_latitude": [deg,min,sec,millionth]|None,
"ref_longitude": [deg,min,sec,millionth]|None,
"ref_elevation": float|None
},
"contexts": [...], # only if include_contexts=True
"warnings": [...]
}
"""
try:
file = IfcStore.get_file()
debug = {"entered": True, "has_ifc": file is not None, "projects": 0, "sites": 0, "contexts": 0}
if file is None:
return {"error": "No IFC file is currently loaded", "debug": debug}
warnings = []
result = {
"georeferenced": False,
"crs": {
"name": None,
"geodetic_datum": None,
"vertical_datum": None,
"map_unit": None
},
"map_conversion": {
"eastings": None,
"northings": None,
"orthogonal_height": None,
"scale": None,
"x_axis_abscissa": None,
"x_axis_ordinate": None
},
"world_coordinate_system": {"origin": None},
"true_north": {"direction_ratios": None},
"site": {
"local_placement_origin": None,
"ref_latitude": None,
"ref_longitude": None,
"ref_elevation": None
},
"contexts": [],
"warnings": warnings,
"debug":debug,
}
# --- IfcProject & RepresentationContexts ---
projects = file.by_type("IfcProject")
debug["projects"] = len(projects)
if projects:
project = projects[0]
contexts = getattr(project, "RepresentationContexts", None) or []
debug["contexts"] = len(contexts)
for ctx in contexts:
ctx_entry = {
"context_identifier": getattr(ctx, "ContextIdentifier", None),
"context_type": getattr(ctx, "ContextType", None),
"world_origin": None,
"true_north": None,
"has_coordinate_operation": []
}
# WorldCoordinateSystem → Local origin
try:
wcs = getattr(ctx, "WorldCoordinateSystem", None)
if wcs and getattr(wcs, "Location", None):
loc = wcs.Location
if getattr(loc, "Coordinates", None):
coords = list(loc.Coordinates)
result["world_coordinate_system"]["origin"] = coords
ctx_entry["world_origin"] = coords
except Exception as e:
warnings.append(f"WorldCoordinateSystem read error: {str(e)}")
# TrueNorth
try:
if hasattr(ctx, "TrueNorth") and ctx.TrueNorth:
tn = ctx.TrueNorth
ratios = list(getattr(tn, "DirectionRatios", []) or [])
result["true_north"]["direction_ratios"] = ratios
ctx_entry["true_north"] = ratios
except Exception as e:
warnings.append(f"TrueNorth read error: {str(e)}")
# HasCoordinateOperation → IfcMapConversion / TargetCRS
try:
if hasattr(ctx, "HasCoordinateOperation") and ctx.HasCoordinateOperation:
for op in ctx.HasCoordinateOperation:
op_entry = {"type": op.is_a(), "target_crs": None, "map_conversion": None}
# TargetCRS
crs = getattr(op, "TargetCRS", None)
if crs:
result["crs"]["name"] = getattr(crs, "Name", None)
result["crs"]["geodetic_datum"] = getattr(crs, "GeodeticDatum", None)
result["crs"]["vertical_datum"] = getattr(crs, "VerticalDatum", None)
try:
map_unit = getattr(crs, "MapUnit", None)
result["crs"]["map_unit"] = map_unit.Name if map_unit else None
except Exception:
result["crs"]["map_unit"] = None
op_entry["target_crs"] = {
"name": result["crs"]["name"],
"geodetic_datum": result["crs"]["geodetic_datum"],
"vertical_datum": result["crs"]["vertical_datum"],
"map_unit": result["crs"]["map_unit"]
}
# IfcMapConversion
if op.is_a("IfcMapConversion"):
mc = {
"eastings": getattr(op, "Eastings", None),
"northings": getattr(op, "Northings", None),
"orthogonal_height": getattr(op, "OrthogonalHeight", None),
"scale": getattr(op, "Scale", None),
"x_axis_abscissa": getattr(op, "XAxisAbscissa", None),
"x_axis_ordinate": getattr(op, "XAxisOrdinate", None)
}
result["map_conversion"].update(mc)
op_entry["map_conversion"] = mc
ctx_entry["has_coordinate_operation"].append(op_entry)
except Exception as e:
warnings.append(f"HasCoordinateOperation read error: {str(e)}")
if include_contexts:
result["contexts"].append(ctx_entry)
else:
warnings.append("IfcProject entity was not found.")
# --- IfcSite (lat/long/alt local origin of placement) ---
try:
sites = file.by_type("IfcSite")
debug["sites"] = len(sites)
if sites:
site = sites[0]
# LocalPlacement
try:
if getattr(site, "ObjectPlacement", None):
placement = site.ObjectPlacement
axisPlacement = getattr(placement, "RelativePlacement", None)
if axisPlacement and getattr(axisPlacement, "Location", None):
loc = axisPlacement.Location
if getattr(loc, "Coordinates", None):
result["site"]["local_placement_origin"] = list(loc.Coordinates)
except Exception as e:
warnings.append(f"IfcSite.ObjectPlacement read error: {str(e)}")
# Lat/Long/Alt
try:
lat = getattr(site, "RefLatitude", None)
lon = getattr(site, "RefLongitude", None)
ele = getattr(site, "RefElevation", None)
result["site"]["ref_latitude"] = list(lat) if lat else None
result["site"]["ref_longitude"] = list(lon) if lon else None
result["site"]["ref_elevation"] = ele
except Exception as e:
warnings.append(f"IfcSite (lat/long/elev) read error: {str(e)}")
else:
warnings.append("IfcSite was not found.")
except Exception as e:
warnings.append(f"Error while querying IfcSite: {str(e)}")
# --- Heuristic to determine georeferencing ---
geo_flags = [
any(result["crs"].values()),
any(v is not None for v in result["map_conversion"].values())
]
result["georeferenced"] = all(geo_flags)
return result
except Exception as e:
import traceback
return {"error": str(e), "traceback": traceback.format_exc()}
@staticmethod
def georeference_ifc_model(
crs_mode: str,
epsg: int = None,
crs_name: str = None,
geodetic_datum: str = None,
map_projection: str = None,
map_zone: str = None,
eastings: float = None,
northings: float = None,
orthogonal_height: float = 0.0,
scale: float = 1.0,
x_axis_abscissa: float = None,
x_axis_ordinate: float = None,
true_north_azimuth_deg: float = None,
context_filter: str = "Model",
context_index: int = None,
site_ref_latitude: list = None, # IFC format [deg, min, sec, millionth]
site_ref_longitude: list = None, # IFC format [deg, min, sec, millionth]
site_ref_elevation: float = None,
site_ref_latitude_dd: float = None, # Decimal degrees (optional)
site_ref_longitude_dd: float = None, # Decimal degrees (optional)
overwrite: bool = False,
dry_run: bool = False,
write_path: str = None,
):
"""
Usage:
Creates/updates IfcProjectedCRS + IfcMapConversion in the opened IFC.
Optionally updates IfcSite.RefLatitude/RefLongitude/RefElevation.
If `pyproj` is available, it can convert Lat/Long (degrees) ⇄ E/N (meters)
according to the given EPSG.
Requirements:
CRS declaration is ALWAYS required:
- crs_mode="epsg" + epsg=XXXX OR
- crs_mode="custom" + (crs_name, geodetic_datum, map_projection [, map_zone])
Minimum MapConversion information:
- eastings + northings
(if missing but lat/long + EPSG + pyproj are available, they are computed)
"""
import math
from bonsai.bim.ifc import IfcStore
file = IfcStore.get_file()
if file is None:
return {"success": False, "error": "No IFC file is currently loaded"}
warnings = []
actions = {"created_crs": False, "created_map_conversion": False,
"updated_map_conversion": False, "updated_site": False,
"overwrote": False, "wrote_file": False}
debug = {}
# ---------- helpers ----------
def dd_to_ifc_dms(dd: float):
"""Converts decimal degrees to [deg, min, sec, millionth] (sign carried by degrees)."""
if dd is None:
return None
sign = -1 if dd < 0 else 1
v = abs(dd)
deg = int(v)
rem = (v - deg) * 60
minutes = int(rem)
sec_float = (rem - minutes) * 60
seconds = int(sec_float)
millionth = int(round((sec_float - seconds) * 1_000_000))
# Normalizes rounding (e.g. 59.999999 → 60)
if millionth == 1_000_000:
seconds += 1
millionth = 0
if seconds == 60:
minutes += 1
seconds = 0
if minutes == 60:
deg += 1
minutes = 0
return [sign * deg, minutes, seconds, millionth]
def select_context():
ctxs = file.by_type("IfcGeometricRepresentationContext") or []
if not ctxs:
return None, "No IfcGeometricRepresentationContext found"
if context_index is not None and 0 <= context_index < len(ctxs):
return ctxs[context_index], None
# By filter (default "Model", case-insensitive)
if context_filter:
for c in ctxs:
if (getattr(c, "ContextType", None) or "").lower() == context_filter.lower():
return c, None
# Fallback to the first one
return ctxs[0], None
# ---------- 1) CRS Validation ----------
if crs_mode not in ("epsg", "custom"):
return {"success": False, "error": "crs_mode must be 'epsg' or 'custom'"}
if crs_mode == "epsg":
if not epsg:
return {"success": False, "error": "epsg code required when crs_mode='epsg'"}
crs_name_final = f"EPSG:{epsg}"
geodetic_datum = geodetic_datum or "WGS84"
map_projection = map_projection or "TransverseMercator" # usual UTM
# map_zone is optional
else:
# custom
missing = [k for k in ("crs_name", "geodetic_datum", "map_projection") if locals().get(k) in (None, "")]
if missing:
return {"success": False, "error": f"Missing fields for custom CRS: {', '.join(missing)}"}
crs_name_final = crs_name
# ---------- 2) Complete E/N from Lat/Long (if missing and pyproj is available) ----------
proj_used = None
try:
if (eastings is None or northings is None) and (site_ref_latitude_dd is not None and site_ref_longitude_dd is not None) and crs_mode == "epsg":
try:
from pyproj import Transformer
# Assume lat/long in WGS84; if the EPSG is not WGS84-derived, pyproj handles the conversion
transformer = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
e, n = transformer.transform(site_ref_longitude_dd, site_ref_latitude_dd)
eastings = e if eastings is None else eastings
northings = n if northings is None else northings
proj_used = f"EPSG:4326->EPSG:{epsg}"
except Exception as _e:
warnings.append(f"Could not convert Lat/Long to E/N: {_e}. Provide eastings/northings manually.")
except Exception as _e:
warnings.append(f"pyproj not available to compute E/N: {_e}. Provide eastings/northings manually.")
# ---------- E/N Validation ----------
if eastings is None or northings is None:
return {"success": False, "error": "eastings and northings are required (or provide lat/long + EPSG with pyproj installed)"}
# ---------- 3) Select context ----------
context, ctx_err = select_context()
if not context:
return {"success": False, "error": ctx_err or "No context found"}
# ---------- 4) Detect existing ones and handle overwrite ----------
# Inverse: context.HasCoordinateOperation is already handled by ifcopenshell as an attribute
existing_ops = list(getattr(context, "HasCoordinateOperation", []) or [])
existing_map = None
existing_crs = None
for op in existing_ops:
if op.is_a("IfcMapConversion"):
existing_map = op
existing_crs = getattr(op, "TargetCRS", None)
break
if existing_map and not overwrite:
return {
"success": True,
"georeferenced": True,
"message": "MapConversion already exists. Use overwrite=True to replace it.",
"context_used": {"identifier": getattr(context, "ContextIdentifier", None), "type": getattr(context, "ContextType", None)},
"map_conversion": {
"eastings": getattr(existing_map, "Eastings", None),
"northings": getattr(existing_map, "Northings", None),
"orthogonal_height": getattr(existing_map, "OrthogonalHeight", None),
"scale": getattr(existing_map, "Scale", None),
"x_axis_abscissa": getattr(existing_map, "XAxisAbscissa", None),
"x_axis_ordinate": getattr(existing_map, "XAxisOrdinate", None),
},
"crs": {
"name": getattr(existing_crs, "Name", None) if existing_crs else None,
"geodetic_datum": getattr(existing_crs, "GeodeticDatum", None) if existing_crs else None,
"map_projection": getattr(existing_crs, "MapProjection", None) if existing_crs else None,
"map_zone": getattr(existing_crs, "MapZone", None) if existing_crs else None,
},
"warnings": warnings,
"actions": actions,
}
# ---------- 5) Build/Update CRS ----------
if existing_crs and overwrite:
actions["overwrote"] = True
try:
file.remove(existing_crs)
except Exception:
warnings.append("Could not remove the existing CRS; a new one will be created anyway.")
# If custom, use the provided values; if EPSG, build the name and defaults
crs_kwargs = {
"Name": crs_name_final,
"GeodeticDatum": geodetic_datum,
"MapProjection": map_projection,
}
if map_zone:
crs_kwargs["MapZone"] = map_zone
crs_entity = file.create_entity("IfcProjectedCRS", **crs_kwargs)
actions["created_crs"] = True
# ---------- 6) Calculate orientation (optional) ----------
# If true_north_azimuth_deg is given as the azimuth from North (model +Y axis) towards East (clockwise),
# We can derive an approximate X vector: X = (cos(az+90°), sin(az+90°)).
if (x_axis_abscissa is None or x_axis_ordinate is None) and (true_north_azimuth_deg is not None):
az = math.radians(true_north_azimuth_deg)
# Estimated X vector rotated 90° from North:
x_axis_abscissa = math.cos(az + math.pi / 2.0)
x_axis_ordinate = math.sin(az + math.pi / 2.0)
# Defaults if still missing
x_axis_abscissa = 1.0 if x_axis_abscissa is None else float(x_axis_abscissa)
x_axis_ordinate = 0.0 if x_axis_ordinate is None else float(x_axis_ordinate)
scale = 1.0 if scale is None else float(scale)
orthogonal_height = 0.0 if orthogonal_height is None else float(orthogonal_height)
# ---------- 7) Build/Update IfcMapConversion ----------
if existing_map and overwrite:
try:
file.remove(existing_map)
except Exception:
warnings.append("Could not remove the existing MapConversion; another one will be created anyway.")
map_kwargs = {
"SourceCRS": context,
"TargetCRS": crs_entity,
"Eastings": float(eastings),
"Northings": float(northings),
"OrthogonalHeight": float(orthogonal_height),
"XAxisAbscissa": float(x_axis_abscissa),
"XAxisOrdinate": float(x_axis_ordinate),
"Scale": float(scale),
}
map_entity = file.create_entity("IfcMapConversion", **map_kwargs)
actions["created_map_conversion"] = True
# ---------- 8) (Optional) Update IfcSite ----------
try:
sites = file.by_type("IfcSite") or []
if sites:
site = sites[0]
# If no IFC lists are provided but decimal degrees are, convert them
if site_ref_latitude is None and site_ref_latitude_dd is not None:
site_ref_latitude = dd_to_ifc_dms(site_ref_latitude_dd)
if site_ref_longitude is None and site_ref_longitude_dd is not None:
site_ref_longitude = dd_to_ifc_dms(site_ref_longitude_dd)
changed = False
if site_ref_latitude is not None:
site.RefLatitude = site_ref_latitude
changed = True
if site_ref_longitude is not None:
site.RefLongitude = site_ref_longitude
changed = True
if site_ref_elevation is not None:
site.RefElevation = float(site_ref_elevation)
changed = True
if changed:
actions["updated_site"] = True
else:
warnings.append("No IfcSite found; lat/long/elevation were not updated.")
except Exception as e:
warnings.append(f"Could not update IfcSite: {e}")
# ---------- 9) (Optional) Save ----------
if write_path and not dry_run:
try:
file.write(write_path)
actions["wrote_file"] = True
except Exception as e:
warnings.append(f"Could not write IFC to'{write_path}': {e}")
# ---------- 10) Response ----------
return {
"success": True,
"georeferenced": True,
"crs": {
"name": getattr(crs_entity, "Name", None),
"geodetic_datum": getattr(crs_entity, "GeodeticDatum", None),
"map_projection": getattr(crs_entity, "MapProjection", None),
"map_zone": getattr(crs_entity, "MapZone", None),
},
"map_conversion": {
"eastings": float(eastings),
"northings": float(northings),
"orthogonal_height": float(orthogonal_height),
"scale": float(scale),
"x_axis_abscissa": float(x_axis_abscissa),
"x_axis_ordinate": float(x_axis_ordinate),
},
"context_used": {
"identifier": getattr(context, "ContextIdentifier", None),
"type": getattr(context, "ContextType", None),
},
"site": {
"ref_latitude": site_ref_latitude,
"ref_longitude": site_ref_longitude,
"ref_elevation": site_ref_elevation,
},
"proj_used": proj_used,
"warnings": warnings,
"actions": actions,
}
@staticmethod
def generate_ids(
title: str,
specs: list,
description: str = "",
author: str = "",
ids_version: str = "",
purpose: str = "",
milestone: str = "",
output_path: str = None,
date_iso: str = None,
):
"""
Generates an .ids file with robust handling of:
- Synonyms: 'name' → 'baseName', 'minValue/maxValue' + inclusivity, 'minOccurs/maxOccurs' → cardinality.
- Operators inside 'value' ("> 30", "≤0.45"), in keys (op/target/threshold/limit), and extracted from 'description'
(ONLY within requirements; never in applicability).
- Correct restriction mapping:
* Numeric → ids.Restriction(base="double" | "integer", options={...})
* Textual (IFCLABEL/TEXT) → ids.Restriction(base="string", options={"pattern": [anchored regexes]})
- Automatic dataType inference with hints
(ThermalTransmittance → IFCTHERMALTRANSMITTANCEMEASURE, IsExternal → IFCBOOLEAN, etc.).
- PredefinedType remains as an Attribute within APPLICABILITY
(NOT absorbed into Entity.predefinedType).
"""
#Libraries/Dependencies
# -----------------------------------------------------------------------------------------------------------
try:
from ifctester import ids
except Exception as e:
return {"ok": False, "error": "Could not import ifctester.ids", "details": str(e)}
import os, datetime, re
from numbers import Number
#Validations
# -----------------------------------------------------------------------------------------------------------
if not isinstance(title, str) or not title.strip():
return {"ok": False, "error": "Invalid or empty 'title' parameter."}
if not isinstance(specs, list) or len(specs) == 0:
return {"ok": False, "error": "You must provide at least one specification in 'specs'."}
# Utils
# -----------------------------------------------------------------------------------------------------------
def _norm_card(c):
"""
Usage:
Normalizes the given cardinality value, ensuring it matches one of the valid terms.
Inputs:
c (str | None): Cardinality value to normalize. Can be 'required', 'optional', or 'prohibited'.
Output:
str | None: Normalized lowercase value if valid, or None if not provided.
Exceptions:
ValueError: Raised if the input value does not correspond to a valid cardinality.
"""
if c is None: return None
c = str(c).strip().lower()
if c in ("required", "optional", "prohibited"): return c
raise ValueError("Invalid cardinality: use 'required', 'optional', or 'prohibited'.")
def _card_from_occurs(minOccurs, maxOccurs):
"""
Usage:
Derives the cardinality ('required' or 'optional') based on the values of minOccurs and maxOccurs.
Inputs:
minOccurs (int | str | None): Minimum number of occurrences. If greater than 0, the field is considered 'required'.
maxOccurs (int | str | None): Maximum number of occurrences. Not used directly, included for completeness.
Output:
str | None: Returns 'required' if minOccurs > 0, 'optional' if minOccurs == 0, or None if conversion fails.
"""
try:
if minOccurs is None: return None
m = int(minOccurs)
return "required" if m > 0 else "optional"
except Exception:
return None
def _is_bool_like(v):
"""
Usage:
Checks whether a given value can be interpreted as a boolean.
Inputs:
v (any): Value to evaluate. Can be of any type (bool, str, int, etc.).
Output:
bool: Returns True if the value represents a boolean-like token
(e.g., True, False, "yes", "no", "1", "0", "y", "n", "t", "f"),
otherwise returns False.
"""
if isinstance(v, bool): return True
if v is None: return False
s = str(v).strip().lower()
return s in ("true", "false", "1", "0", "yes", "no", "y", "n", "t", "f")
def _to_bool_token(v):
"""
Usage:
Converts a boolean-like value into a standardized string token ("TRUE" or "FALSE").
Inputs:
v (any): Value to convert. Can be a boolean, string, or numeric value representing truthiness.
Output:
str | None: Returns "TRUE" or "FALSE" if the value matches a recognized boolean pattern,
or None if it cannot be interpreted as boolean.
"""
if isinstance(v, bool): return "TRUE" if v else "FALSE"
s = str(v).strip().lower()
if s in ("true", "1", "yes", "y", "t"): return "TRUE"
if s in ("false", "0", "no", "n", "f"): return "FALSE"
return None
# Hints for *MEASURE* types and by property name
MEASURE_HINTS = {
"THERMALTRANSMITTANCE": "IFCTHERMALTRANSMITTANCEMEASURE",
"UVALUE": "IFCTHERMALTRANSMITTANCEMEASURE",
"RATIOMEASURE": "IFCRATIOMEASURE",
"AREAMEASURE": "IFCAREAMEASURE",
"LENGTHMEASURE": "IFCLENGTHMEASURE",
"SOUNDPRESSURELEVELMEASURE": "IFCSOUNDPRESSURELEVELMEASURE",
}
PROPERTY_DATATYPE_HINTS = {
"THERMALTRANSMITTANCE": "IFCTHERMALTRANSMITTANCEMEASURE",
"ISEXTERNAL": "IFCBOOLEAN",
"ACOUSTICRATING": "IFCLABEL",
}
def _norm_ifc_version(v: str | None) -> str | None:
"""
Usage:
Normalizes the given IFC schema version string to a standardized format.
Inputs:
v (str | None): Input version value (e.g., "4", "IFC 4", "2x3", "IFC4.3").
Output:
str | None: Returns the normalized IFC version (e.g., "IFC4", "IFC2X3", "IFC4X3"),
or None if the input is empty or invalid.
"""
if not v: return None
s = str(v).strip().upper()
m = {"4": "IFC4", "IFC 4": "IFC4", "2X3": "IFC2X3", "IFC 2X3": "IFC2X3", "IFC4.3": "IFC4X3"}
return m.get(s, s)
def _strip_ifc_prefix(dt: str | None) -> str | None:
"""
Usage:
Removes leading and trailing spaces from the given string and converts it to uppercase.
Typically used to normalize IFC data type names.
Inputs:
dt (str | None): Data type string to normalize (e.g., " ifcreal ").
Output:
str | None: Uppercase, trimmed string (e.g., "IFCREAL"), or None if the input is empty or None.
"""
return dt.strip().upper() if dt else None
def _is_number_like(v) -> bool:
"""
Usage:
Checks whether the given value can be interpreted as a numeric value.
Inputs:
v (any): Value to evaluate. Can be of any type (int, float, str, etc.).
Output:
bool: Returns True if the value represents a number (including numeric strings like "3.5" or "2,7"),
otherwise returns False.
"""
if isinstance(v, Number): return True
if v is None: return False
try:
float(str(v).strip().replace(",", "."))
return True
except Exception:
return False
def _guess_numeric_base_from_ifc(dt_upper: str | None) -> str:
"""
Usage:
Determines the numeric base type ('integer' or 'double') from an IFC data type string.
Inputs:
dt_upper (str | None): Uppercase IFC data type name (e.g., "IFCINTEGER", "IFCREAL").
Output:
str: Returns "integer" if the type contains "INTEGER"; otherwise returns "double".
Defaults to "double" when no input is provided.
"""
if not dt_upper: return "double"
if "INTEGER" in dt_upper: return "integer"
return "double"
# comparators in string ("> 30", "<=0.45", "≥3", "≤ 3")
_cmp_regex = re.compile(r"^\s*(>=|=>|≤|<=|≥|>|<)\s*([0-9]+(?:[.,][0-9]+)?)\s*$")
_normalize_op = {">=":">=", "=>":">=", "≥":">=", "<=":"<=", "≤":"<="}
def _extract_op_target_from_string(s: str):
"""
Usage:
Extracts a comparison operator and its numeric target value from a string expression.
Inputs:
s (str): String containing a comparison, e.g., "> 30", "<=0.45", "≥3", or "≤ 3".
Output:
tuple(str | None, float | None): Returns a tuple (operator, target_value),
where operator is one of ">", ">=", "<", or "<=".
Returns (None, None) if the string does not match a valid pattern.
"""
m = _cmp_regex.match(s)
if not m: return None, None
op, num = m.group(1), m.group(2)
op = _normalize_op.get(op, op)
try: tgt = float(num.replace(",", "."))
except Exception: return None, None
return op, tgt
# English descriptions (>= before >)
_desc_ops = [
(r"(greater\s+than\s+or\s+equal\s+to|greater\s+or\s+equal\s+to|equal\s+or\s+greater\s+than|≥)", ">="),
(r"(less\s+than\s+or\s+equal\s+to|not\s+greater\s+than|≤|at\s+most|maximum)", "<="),
(r"(greater\s+than|more\s+than|>)", ">"),
(r"(less\s+than|fewer\s+than|<)", "<"),
]
_num_regex = re.compile(r"([0-9]+(?:[.,][0-9]+)?)")
def _extract_from_description(desc: str):
"""
Usage:
Extracts a comparison operator and numeric target value from a descriptive text.
Designed to interpret expressions such as "greater than 30" or "less than or equal to 0.45".
Inputs:
desc (str): Description text potentially containing a numeric comparison.
Output:
tuple(str | None, float | None): Returns a tuple (operator, target_value),
where operator is one of ">", ">=", "<", or "<=",
and target_value is the numeric value extracted.
Returns (None, None) if no valid pattern is found.
"""
if not desc: return None, None
text = desc.strip().lower()
for pat, op in _desc_ops:
if re.search(pat, text):
m = _num_regex.search(text)
if m:
try:
tgt = float(m.group(1).replace(",", "."))
return op, tgt
except Exception:
pass
return None, None
# anchored regexes for integers (numeric fallback for decimals)
def _regex_for_threshold(threshold: float, op: str) -> list[str]:
"""
Usage:
Builds one or more anchored regular expressions to validate integer values
against a numeric threshold and comparison operator.
For non-integer thresholds, returns a generic numeric pattern as fallback.
Inputs:
threshold (float): Numeric limit used for the comparison (e.g., 30, 10.5).
op (str): Comparison operator, one of ">", ">=", "<", or "<=".
Output:
list[str]: A list containing one or more anchored regex patterns that match
integer strings satisfying the given condition.
Returns a generic numeric regex pattern as fallback for decimals.
"""
if abs(threshold - round(threshold)) < 1e-9:
t = int(round(threshold))
def gt_int(n):
if n <= 8: return rf"^([{n+1}-9]|[1-9]\d|[1-9]\d{{2,}})$"
if n <= 98:
tens, units = divmod(n + 1, 10)
p1 = rf"{tens}[{units}-9]" if units > 0 else rf"{tens}\d"
p2 = rf"[{tens+1}-9]\d" if tens < 9 else ""
parts = [p1, p2, r"[1-9]\d{2,}"]
return "^(" + "|".join([p for p in parts if p]) + ")$"
return r"^[1-9]\d{2,}$"
def ge_int(n):
if n <= 9: return rf"^([{n}-9]|[1-9]\d|[1-9]\d{{2,}})$"
if n <= 99:
tens, units = divmod(n, 10)
p1 = rf"{tens}[{units}-9]"
p2 = rf"[{tens+1}-9]\d" if tens < 9 else ""
parts = [p1, p2, r"[1-9]\d{2,}"]
return "^(" + "|".join([p for p in parts if p]) + ")$"
return r"^[1-9]\d{2,}$"
def lt_int(n):
if n <= 0: return r"^(?!)$"
if n <= 10: return rf"^[0-9]$" if n == 10 else rf"^[0-{n-1}]$"
tens, units = divmod(n - 1, 10)
if tens == 1: return r"^([0-9]|1[0-9])$"
return rf"^([0-9]|[1-{tens-1}]\d|{tens}[0-{units}])$"
def le_int(n):
if n < 10: return rf"^[0-{n}]$"
tens, units = divmod(n, 10)
if tens == 1:
return r"^([0-9]|1[0-9])$" if units == 9 else rf"^([0-9]|1[0-{units}])$"
parts = [r"[0-9]"]
if tens > 1: parts.append(rf"[1-{tens-1}]\d")
parts.append(rf"{tens}[0-{units}]")
return "^(" + "|".join(parts) + ")$"
if op == ">": return [gt_int(t)]
elif op == ">=": return [ge_int(t)]
elif op == "<": return [lt_int(t)]
elif op == "<=": return [le_int(t)]
return [r"^\d+(?:[.,]\d+)?$"] # fallback for decimals (plain numeric string)
def _build_restriction_for_text(op: str | None, target, bounds: dict):
"""
Usage:
Builds a text-based IDS restriction (ids.Restriction) using regex patterns derived
from numeric thresholds and comparison operators.
Used when a property has textual dataType (e.g., IFCLABEL) but represents numeric conditions.
Inputs:
op (str | None): Comparison operator (">", ">=", "<", "<=") if explicitly provided.
target (any): Target value for the comparison. Can be numeric or string.
bounds (dict): Dictionary of limit values such as
{"minInclusive": ..., "maxExclusive": ..., "maxInclusive": ...}.
Output:
ids.Restriction | None: Returns an ids.Restriction object with regex patterns
for matching the specified numeric range in string form,
or None if no valid pattern can be built.
"""
if op and target is not None and _is_number_like(target):
return ids.Restriction(base="string", options={"pattern": _regex_for_threshold(float(target), op)})
patterns = []
if bounds.get("minExclusive") is not None:
patterns += _regex_for_threshold(float(bounds["minExclusive"]), ">")
if bounds.get("minInclusive") is not None:
patterns += _regex_for_threshold(float(bounds["minInclusive"]), ">=")
if bounds.get("maxExclusive") is not None:
patterns += _regex_for_threshold(float(bounds["maxExclusive"]), "<")
if bounds.get("maxInclusive") is not None:
patterns += _regex_for_threshold(float(bounds["maxInclusive"]), "<=")
return ids.Restriction(base="string", options={"pattern": patterns}) if patterns else None
def _build_numeric_restriction(dt_upper: str | None, op: str | None, target, bounds: dict):
"""
Usage:
Builds a numeric IDS restriction (ids.Restriction) from a data type, comparison operator,
target value, and optional numeric bounds.
Inputs:
dt_upper (str | None): Uppercase IFC data type name (e.g., "IFCREAL", "IFCINTEGER").
op (str | None): Comparison operator (">", ">=", "<", "<=") if provided.
target (any): Target value for the comparison. Converted to float when applicable.
bounds (dict): Dictionary containing optional boundary values such as
{"minInclusive": ..., "maxExclusive": ..., "maxInclusive": ...}.
Output:
ids.Restriction | None: Returns an ids.Restriction object with the appropriate numeric limits,
or None if no valid restriction can be created.
"""
if not (op or any(v is not None for v in bounds.values())): return None
base_num = _guess_numeric_base_from_ifc(dt_upper)
opts = {}
if op and target is not None:
v = float(str(target).replace(",", "."))
if op == ">": opts["minExclusive"] = v
elif op == ">=": opts["minInclusive"] = v
elif op == "<": opts["maxExclusive"] = v
elif op == "<=": opts["maxInclusive"] = v
for k in ("minInclusive","maxInclusive","minExclusive","maxExclusive"):
if bounds.get(k) is not None:
opts[k] = float(str(bounds[k]).replace(",", "."))
if not opts: return None
return ids.Restriction(base=base_num, options=opts)
def _infer_ids_datatype(pset: str | None, baseName: str | None,
provided_dt: str | None, value, op: str | None, bounds: dict) -> str:
"""
Usage:
Infers the appropriate IFC data type (e.g., IFCREAL, IFCINTEGER, IFCBOOLEAN, IFCLABEL)
for a given property based on its name, provided data type, value, and restrictions.
Inputs:
pset (str | None): Name of the property set to which the property belongs.
baseName (str | None): Base name of the property (e.g., "ThermalTransmittance", "IsExternal").
provided_dt (str | None): Data type explicitly provided in the input, if any.
value (any): Property value or an ids.Restriction object.
op (str | None): Comparison operator (">", ">=", "<", "<=") if defined.
bounds (dict): Dictionary containing limit values such as
{"minInclusive": ..., "maxExclusive": ..., "maxInclusive": ...}.
Output:
str: Returns the inferred IFC data type string, such as "IFCREAL", "IFCINTEGER",
"IFCBOOLEAN", or "IFCLABEL".
"""
# if a dataType is provided, normalize and promote it if applicable
if provided_dt:
dtU = _strip_ifc_prefix(provided_dt)
if baseName and dtU in ("IFCREAL", "IFCNUMBER", "NUMBER", "REAL"):
hint = PROPERTY_DATATYPE_HINTS.get(str(baseName).strip().upper())
if hint: return hint
if dtU in MEASURE_HINTS: return MEASURE_HINTS[dtU]
return dtU
# hints by name
if baseName:
hint = PROPERTY_DATATYPE_HINTS.get(str(baseName).strip().upper())
if hint: return hint
# value = Restriction
if isinstance(value, ids.Restriction):
base = getattr(value, "base", "").lower()
if base in ("integer",): return "IFCINTEGER"
if base in ("double","number","real","float"): return "IFCREAL"
return "IFCLABEL"
# if op/bounds -> numeric
if op or any(v is not None for v in bounds.values()):
return "IFCREAL"
# booleans
if _is_bool_like(value): return "IFCBOOLEAN"
# literal numbers
if _is_number_like(value):
try:
iv = int(str(value))
if float(str(value)) == float(iv): return "IFCINTEGER"
except Exception:
pass
return "IFCREAL"
# text
return "IFCLABEL"
# (optional) Absorption of PredefinedType into Entity.predefinedType — DISABLED
def _absorb_predefined_type(applicability_list: list):
"""
Usage:
Transfers the value of a PREDEFINEDTYPE attribute into the corresponding Entity's
predefinedType field within the applicability list.
This operation effectively absorbs the PREDEFINEDTYPE entry into the Entity definition.
Inputs:
applicability_list (list): List of facet dictionaries containing 'Entity' and 'Attribute' definitions.
Output:
list: The updated applicability list where the PREDEFINEDTYPE value has been moved
to the Entity's 'predefinedType' field, if applicable.
Returns the original list if no valid Entity or PREDEFINEDTYPE attribute is found.
"""
if not isinstance(applicability_list, list): return applicability_list
idx = next((i for i,f in enumerate(applicability_list) if (f.get("type") == "Entity")), None)
if idx is None: return applicability_list
for i,f in enumerate(list(applicability_list)):
if f.get("type") == "Attribute" and str(f.get("name","")).strip().upper() == "PREDEFINEDTYPE":
val = f.get("value")
if val not in (None, ""):
applicability_list[idx]["predefinedType"] = val
applicability_list.pop(i)
break
return applicability_list
# IDS Root
# -----------------------------------------------------------------------------------------------------------
try:
ids_root = ids.Ids(
title=(title or "Untitled"),
description=(description or None),
author=(author or None),
version=(str(ids_version) if ids_version else None),
purpose=(purpose or None),
milestone=(milestone or None),
date=(date_iso or datetime.date.today().isoformat()),
)
try: ids_root.title = (title or "Untitled")
except Exception: pass
try: ids_root.info.title = (title or "Untitled")
except Exception: pass
except Exception as e:
return {"ok": False, "error": "Could not initialize the IDS", "details": str(e)}
# Facets (with context)
# -----------------------------------------------------------------------------------------------------------
def _facet_from_dict(f, spec_desc: str | None, context: str):
"""
Usage:
Builds an IDS facet object (e.g., Entity, Attribute, Property, Material, Classification, or PartOf)
from a dictionary definition. Handles data normalization, type inference, comparison extraction,
and restriction creation for both applicability and requirements contexts.
Inputs:
f (dict): Dictionary describing a facet, including its type and relevant attributes.
spec_desc (str | None): Optional specification description used to infer operators or targets
when not explicitly provided.
context (str): Indicates the facet context, either 'applicability' or 'requirements'.
Only in 'requirements' can operator/target be extracted from the description.
Output:
ids.Entity | ids.Attribute | ids.Property | ids.Material | ids.Classification | ids.PartOf:
Returns the corresponding ids.* object based on the facet type.
Exceptions:
ValueError: Raised if the facet type is unsupported or required fields are missing
(e.g., Property without propertySet or baseName, Attribute without name).
"""
t = (f.get("type") or "").strip()
if t == "Entity":
ent_name = f.get("name", "") or f.get("entity", "") or f.get("Name", "")
ent_name = ent_name.strip()
if ent_name.lower().startswith("ifc") and not ent_name.isupper():
ent_name = ent_name.upper() # 'IfcWall' -> 'IFCWALL'
return ids.Entity(
name=ent_name,
predefinedType=f.get("predefinedType", ""), # we keep it separate (not absorbed)
instructions=f.get("instructions", ""),
)
elif t == "Attribute":
name = f.get("name") or f.get("Name")
if not name: raise ValueError("Attribute requires 'name'.")
kwargs = dict(name=name)
if f.get("value") not in (None, ""):
val = f["value"]
if _is_bool_like(val):
tok = _to_bool_token(val)
kwargs["value"] = tok if tok else val
else:
kwargs["value"] = val
# Cardinality from occurs
card = _card_from_occurs(f.get("minOccurs"), f.get("maxOccurs"))
if card: kwargs["cardinality"] = card
if f.get("cardinality"): kwargs["cardinality"] = _norm_card(f.get("cardinality"))
if f.get("instructions"): kwargs["instructions"] = f["instructions"]
return ids.Attribute(**kwargs)
elif t == "Property":
pset = f.get("propertySet") or f.get("pset") or f.get("psetName")
base = f.get("baseName") or f.get("name") or f.get("Name")
if not pset or not base: raise ValueError("Property requires 'propertySet' and 'baseName'.")
val_in = f.get("value", None)
bounds = {
"minInclusive": f.get("minInclusive"),
"maxInclusive": f.get("maxInclusive"),
"minExclusive": f.get("minExclusive"),
"maxExclusive": f.get("maxExclusive"),
}
# minValue/maxValue + inclusivity
if f.get("minValue") is not None:
if bool(f.get("minInclusive")): bounds["minInclusive"] = f.get("minValue")
else: bounds["minExclusive"] = f.get("minValue")
if f.get("maxValue") is not None:
if bool(f.get("maxInclusive")): bounds["maxInclusive"] = f.get("maxValue")
else: bounds["maxExclusive"] = f.get("maxValue")
if isinstance(val_in, dict):
for k in ("minInclusive","maxInclusive","minExclusive","maxExclusive"):
if k in val_in and bounds.get(k) is None:
bounds[k] = val_in[k]
# explicit operator
op = f.get("op") or f.get("operator") or f.get("comparison") or f.get("cmp") or f.get("relation")
target = f.get("target") or f.get("threshold") or f.get("limit")
# operator in 'value' string ("> 30")
if target is None and isinstance(val_in, str):
_op2, _tg2 = _extract_op_target_from_string(val_in)
if _op2 and _tg2 is not None:
op, target, val_in = _op2, _tg2, None
# ONLY IN REQUIREMENTS: extract from description
if context == "requirements" and (not op and all(v is None for v in bounds.values()) and target is None and spec_desc):
_op3, _tg3 = _extract_from_description(spec_desc)
if _op3 and _tg3 is not None:
op, target = _op3, _tg3
# cardinality from occurs
card = _card_from_occurs(f.get("minOccurs"), f.get("maxOccurs"))
dt = _infer_ids_datatype(pset, base, f.get("dataType"), val_in, op, bounds)
# boolean normalization
if _is_bool_like(val_in):
tok = _to_bool_token(val_in)
if tok is not None:
val_in = tok
if not dt: dt = "IFCBOOLEAN"
# Restriction when applicable
restriction_obj = None
if op or any(v is not None for v in bounds.values()):
if dt in ("IFCLABEL","IFCTEXT"):
restriction_obj = _build_restriction_for_text(op, target if target is not None else val_in, bounds)
else:
restriction_obj = _build_numeric_restriction(dt, op, target if target is not None else val_in, bounds)
if isinstance(val_in, ids.Restriction):
restriction_obj = val_in
kwargs = dict(propertySet=pset, baseName=base)
if restriction_obj is not None:
kwargs["value"] = restriction_obj
if dt: kwargs["dataType"] = dt
else:
if val_in not in (None, ""): kwargs["value"] = val_in
if dt: kwargs["dataType"] = dt
if f.get("uri"): kwargs["uri"] = f["uri"]
if f.get("instructions"): kwargs["instructions"] = f["instructions"]
if card: kwargs["cardinality"] = card
if f.get("cardinality"): kwargs["cardinality"] = _norm_card(f.get("cardinality"))
if (op or any(v is not None for v in bounds.values())) and "cardinality" not in kwargs:
kwargs["cardinality"] = "required"
return ids.Property(**kwargs)
elif t == "Material":
kwargs = {}
if f.get("value"): kwargs["value"] = f["value"]
if f.get("uri"): kwargs["uri"] = f["uri"]
if f.get("cardinality"): kwargs["cardinality"] = _norm_card(f["cardinality"])
if f.get("instructions"): kwargs["instructions"] = f["instructions"]
return ids.Material(**kwargs)
elif t == "Classification":
return ids.Classification(
value=f.get("value", ""),
system=f.get("system", ""),
uri=f.get("uri", ""),
cardinality=_norm_card(f.get("cardinality")),
instructions=f.get("instructions", ""),
)
elif t == "PartOf":
return ids.PartOf(
name=f.get("name", ""),
predefinedType=f.get("predefinedType", ""),
relation=f.get("relation", ""),
cardinality=_norm_card(f.get("cardinality")),
instructions=f.get("instructions", ""),
)
else:
raise ValueError(f"Unsupported or empty facet type: '{t}'.")
# Construction
# -----------------------------------------------------------------------------------------------------------
total_specs = total_app = total_req = 0
try:
for s in specs:
if not isinstance(s, dict):
raise ValueError("Each 'spec' must be a dict.")
applicability = s.get("applicability", [])
requirements = s.get("requirements", [])
if not isinstance(applicability, list) or not isinstance(requirements, list):
raise ValueError("'applicability' and 'requirements' must be lists.")
# Do NOT absorb PredefinedType (it remains as an Attribute in applicability)
# applicability = _absorb_predefined_type(applicability)
spec_obj = ids.Specification()
if s.get("name"):
try: spec_obj.name = s["name"]
except Exception: pass
if s.get("description"):
try: spec_obj.description = s["description"]
except Exception: pass
# ifcVersion: use the provided one; if not, default to IFC4
canon = _norm_ifc_version(s.get("ifcVersion") or "IFC4")
try: spec_obj.ifcVersion = canon
except Exception: pass
for f in applicability:
facet = _facet_from_dict(f, s.get("description"), context="applicability")
spec_obj.applicability.append(facet); total_app += 1
for f in requirements:
facet = _facet_from_dict(f, s.get("description"), context="requirements")
spec_obj.requirements.append(facet); total_req += 1
ids_root.specifications.append(spec_obj); total_specs += 1
except Exception as e:
return {"ok": False, "error": "Error while building the IDS specifications", "details": str(e)}
if total_specs == 0:
return {"ok": False, "error": "No Specification was created. Check 'specs'."}
# Saved
# -----------------------------------------------------------------------------------------------------------
try:
if not output_path:
safe_title = "".join(c for c in title if c.isalnum() or c in (" ","-","_")).rstrip() or "ids"
today = (date_iso if date_iso else datetime.date.today().isoformat())
output_path = os.path.abspath(f"{safe_title}_{today}.ids")
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
ids_root.to_xml(output_path)
except Exception as e:
return {"ok": False, "error": "Could not save the IDS file", "details": str(e)}
return {
"ok": True,
"output_path": output_path,
"message": f"IDS '{title}' generated. Specs: {total_specs}, facets: {total_app} appl. / {total_req} req."
}
#endregion
def extract_quantities(entity, blender_name=None):
"""
Extract quantity information from an IFC entity.
Parameters:
entity: IFC entity object
blender_name: Optional Blender object name
Returns:
Dictionary with element info and quantities
"""
try:
# Get all property sets
psets = ifcopenshell.util.element.get_psets(entity)
# Basic element info
element_data = {
"id": entity.GlobalId if hasattr(entity, "GlobalId") else f"Entity_{entity.id()}",
"name": entity.Name if hasattr(entity, "Name") else None,
"type": entity.is_a(),
"blender_name": blender_name,
"quantities": {}
}
# Look for quantity information in different property sets
quantity_sources = ["BaseQuantities", "ArchiCADQuantities", "Qto_WallBaseQuantities",
"Qto_SlabBaseQuantities", "Qto_BeamBaseQuantities", "Qto_ColumnBaseQuantities"]
# Extract quantities from property sets - keep original names
for pset_name in quantity_sources:
if pset_name in psets:
pset_data = psets[pset_name]
for prop_name, prop_value in pset_data.items():
# Only include numeric values and skip the 'id' field
if isinstance(prop_value, (int, float)) and prop_name != 'id':
element_data["quantities"][prop_name] = prop_value
return element_data if element_data["quantities"] else None
except Exception as e:
return None
# Blender UI Panel
class BLENDERMCP_PT_Panel(bpy.types.Panel):
bl_label = "Bonsai MCP"
bl_idname = "BLENDERMCP_PT_Panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Bonsai MCP'
def draw(self, context):
layout = self.layout
scene = context.scene
layout.prop(scene, "blendermcp_port")
if not scene.blendermcp_server_running:
layout.operator("blendermcp.start_server", text="Start MCP Server")
else:
layout.operator("blendermcp.stop_server", text="Stop MCP Server")
layout.label(text=f"Running on port {scene.blendermcp_port}")
# Operator to start the server
class BLENDERMCP_OT_StartServer(bpy.types.Operator):
bl_idname = "blendermcp.start_server"
bl_label = "Connect to Claude"
bl_description = "Start the BlenderMCP server to connect with Claude"
def execute(self, context):
scene = context.scene
# Create a new server instance
if not hasattr(bpy.types, "blendermcp_server") or not bpy.types.blendermcp_server:
bpy.types.blendermcp_server = BlenderMCPServer(port=scene.blendermcp_port)
# Start the server
bpy.types.blendermcp_server.start()
scene.blendermcp_server_running = True
return {'FINISHED'}
# Operator to stop the server
class BLENDERMCP_OT_StopServer(bpy.types.Operator):
bl_idname = "blendermcp.stop_server"
bl_label = "Stop the connection to Claude"
bl_description = "Stop the connection to Claude"
def execute(self, context):
scene = context.scene
# Stop the server if it exists
if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server:
bpy.types.blendermcp_server.stop()
del bpy.types.blendermcp_server
scene.blendermcp_server_running = False
return {'FINISHED'}
# Registration functions
def register():
bpy.types.Scene.blendermcp_port = IntProperty(
name="Port",
description="Port for the BlenderMCP server",
default=9876,
min=1024,
max=65535
)
bpy.types.Scene.blendermcp_server_running = bpy.props.BoolProperty(
name="Server Running",
default=False
)
bpy.utils.register_class(BLENDERMCP_PT_Panel)
bpy.utils.register_class(BLENDERMCP_OT_StartServer)
bpy.utils.register_class(BLENDERMCP_OT_StopServer)
print("BlenderMCP addon registered")
def unregister():
# Stop the server if it's running
if hasattr(bpy.types, "blendermcp_server") and bpy.types.blendermcp_server:
bpy.types.blendermcp_server.stop()
del bpy.types.blendermcp_server
bpy.utils.unregister_class(BLENDERMCP_PT_Panel)
bpy.utils.unregister_class(BLENDERMCP_OT_StartServer)
bpy.utils.unregister_class(BLENDERMCP_OT_StopServer)
del bpy.types.Scene.blendermcp_port
del bpy.types.Scene.blendermcp_server_running
print("BlenderMCP addon unregistered")
if __name__ == "__main__":
register()