Skip to main content
Glama

Blender MCP Server

by llm-use
blender_mcp.pyโ€ข191 kB
""" BLENDER MCP COMPLETE SERVER - PRODUCTION VERSION Enterprise-grade MCP server for complete Blender control via API Version: 3.0.0 """ import bpy import sys import subprocess import os import importlib import math import queue import time import functools import uuid import traceback import logging from enum import Enum from typing import List, Dict, Any, Optional, Union, Tuple from pathlib import Path from datetime import datetime from functools import lru_cache bl_info = { "name": "MCP Complete Server for Blender - Production", "author": "MCP Blender Team", "version": (3, 0, 0), "blender": (3, 0, 0), "location": "View3D > N-Panel > MCP Server", "description": "Production-ready MCP server for complete Blender control via API", "category": "Development", } # ============================================ # ๐Ÿ”ง CONFIGURATION & LOGGING # ============================================ # Setup logging logging.basicConfig( level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [Blender MCP] %(message)s', datefmt='%Y-%m-%d %H:%M:%S' ) logger = logging.getLogger(__name__) # Configuration class Config: """Server configuration""" HOST = "0.0.0.0" PORT = 8000 QUEUE_TIMEOUT = 30.0 QUEUE_CHECK_INTERVAL = 0.01 MAX_QUEUE_SIZE = 1000 THREAD_SAFE_OPERATIONS = True AUTO_INSTALL_PACKAGES = True POLYMCP_PATH = r'your_path' ENABLE_CACHING = True CACHE_SIZE = 256 # ============================================ # ๐Ÿ“ฆ PACKAGE MANAGEMENT # ============================================ def check_and_install_packages(): """Check and install required packages with proper error handling""" required_packages = { 'fastapi': 'fastapi', 'uvicorn': 'uvicorn[standard]', 'pydantic': 'pydantic', 'docstring_parser': 'docstring-parser', 'numpy': 'numpy', } python_exe = sys.executable missing_packages = [] logger.info("Checking required packages...") for module_name, install_name in required_packages.items(): try: importlib.import_module(module_name) logger.info(f"โœ“ {module_name} found") except ImportError: missing_packages.append(install_name) logger.warning(f"โœ— {module_name} missing") if missing_packages and Config.AUTO_INSTALL_PACKAGES: logger.info("Installing missing packages...") try: # Ensure pip is available and updated subprocess.check_call([python_exe, "-m", "ensurepip"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.check_call([python_exe, "-m", "pip", "install", "--upgrade", "pip"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # Install missing packages for package in missing_packages: logger.info(f"Installing {package}...") try: subprocess.check_call([python_exe, "-m", "pip", "install", package], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) logger.info(f"โœ“ {package} installed successfully") except subprocess.CalledProcessError as e: logger.error(f"โœ— Failed to install {package}: {e}") return False except Exception as e: logger.error(f"Package installation failed: {e}") return False return True # Check packages on import packages_ok = check_and_install_packages() if packages_ok: try: # Add polymcp path if Config.POLYMCP_PATH not in sys.path: sys.path.append(Config.POLYMCP_PATH) import uvicorn from fastapi import FastAPI, HTTPException from polymcp_toolkit import expose_tools import numpy as np logger.info("โœ“ All packages loaded successfully") except ImportError as e: logger.error(f"Import error after installation: {e}") logger.info("Please restart Blender and try again") else: logger.error("Failed to install required packages") import bmesh import mathutils import threading import base64 import tempfile import json import hashlib from concurrent.futures import ThreadPoolExecutor # ============================================ # ๐Ÿ”„ THREAD-SAFE EXECUTION SYSTEM # ============================================ class ThreadSafeExecutor: """Production-grade thread-safe execution system for Blender operations""" def __init__(self): self.execution_queue = queue.Queue(maxsize=Config.MAX_QUEUE_SIZE) self.result_store = {} self.is_running = False self.executor = ThreadPoolExecutor(max_workers=1) def start(self): """Start the queue processor""" if not self.is_running: if not bpy.app.timers.is_registered(self._process_queue): bpy.app.timers.register(self._process_queue) self.is_running = True logger.info("Thread-safe executor started") def stop(self): """Stop the queue processor""" if self.is_running: if bpy.app.timers.is_registered(self._process_queue): bpy.app.timers.unregister(self._process_queue) self.is_running = False logger.info("Thread-safe executor stopped") def _process_queue(self): """Process pending operations in the main thread""" try: while not self.execution_queue.empty(): try: request = self.execution_queue.get_nowait() request_id = request['id'] func = request['func'] args = request['args'] kwargs = request['kwargs'] try: # Execute in main thread result = func(*args, **kwargs) self.result_store[request_id] = { 'status': 'success', 'result': result, 'timestamp': time.time() } except Exception as e: error_details = { 'error': str(e), 'traceback': traceback.format_exc(), 'function': func.__name__, 'args': str(args)[:200], 'kwargs': str(kwargs)[:200] } self.result_store[request_id] = { 'status': 'error', 'error': error_details, 'timestamp': time.time() } logger.error(f"Error executing {func.__name__}: {e}") except queue.Empty: break except Exception as e: logger.error(f"Queue processor error: {e}") # Clean old results (older than 5 minutes) current_time = time.time() expired_ids = [ rid for rid, data in self.result_store.items() if current_time - data['timestamp'] > 300 ] for rid in expired_ids: del self.result_store[rid] return Config.QUEUE_CHECK_INTERVAL def execute(self, func, *args, **kwargs): """Execute a function in the main thread and return the result""" request_id = str(uuid.uuid4()) # Add to queue try: self.execution_queue.put({ 'id': request_id, 'func': func, 'args': args, 'kwargs': kwargs }, timeout=1.0) except queue.Full: raise RuntimeError("Execution queue is full") # Wait for result start_time = time.time() while time.time() - start_time < Config.QUEUE_TIMEOUT: if request_id in self.result_store: result_data = self.result_store.pop(request_id) if result_data['status'] == 'success': return result_data['result'] else: error_info = result_data['error'] raise RuntimeError(f"Execution failed: {error_info['error']}") time.sleep(0.001) raise TimeoutError(f"Operation timed out after {Config.QUEUE_TIMEOUT}s") # Global executor instance thread_executor = ThreadSafeExecutor() def thread_safe(func): """Decorator to make functions thread-safe""" @functools.wraps(func) def wrapper(*args, **kwargs): if Config.THREAD_SAFE_OPERATIONS: return thread_executor.execute(func, *args, **kwargs) else: return func(*args, **kwargs) return wrapper @thread_safe def capture_viewport_image( camera_name: Optional[str] = None, resolution: Tuple[int, int] = (512, 512), samples: int = 8, return_base64: bool = True, include_overlays: bool = False ) -> Dict[str, Any]: """ Capture viewport for VLM analysis. """ # Store original settings scene = bpy.context.scene original_res_x = scene.render.resolution_x original_res_y = scene.render.resolution_y original_samples = scene.eevee.taa_render_samples # Set quick render settings scene.render.resolution_x = resolution[0] scene.render.resolution_y = resolution[1] scene.render.engine = 'BLENDER_EEVEE' scene.eevee.taa_render_samples = samples # Set camera if specified if camera_name: cam = bpy.data.objects.get(camera_name) if cam: scene.camera = cam # Render to temp file tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) scene.render.filepath = tmp.name # Quick viewport render bpy.ops.render.opengl(write_still=True, view_context=True) # Restore settings scene.render.resolution_x = original_res_x scene.render.resolution_y = original_res_y scene.eevee.taa_render_samples = original_samples result = { "resolution": resolution, "filepath": tmp.name } if return_base64: with open(tmp.name, 'rb') as f: result["image_base64"] = base64.b64encode(f.read()).decode('utf-8') os.unlink(tmp.name) return result @thread_safe def analyze_spatial_layout() -> Dict[str, Any]: """ Generate spatial description for VLM context. """ objects = [] for obj in bpy.data.objects[:20]: # Limit to 20 for performance if obj.type in ['MESH', 'CURVE', 'FONT']: bbox = [obj.matrix_world @ mathutils.Vector(c) for c in obj.bound_box] center = sum(bbox, mathutils.Vector()) / 8 objects.append({ "name": obj.name, "type": obj.type, "center": [round(c, 2) for c in center], "size": [round(d, 2) for d in obj.dimensions] }) # Group by proximity groups = [] for obj in objects[:5]: # Analyze first 5 nearby = [] for other in objects: if obj["name"] != other["name"]: dist = (mathutils.Vector(obj["center"]) - mathutils.Vector(other["center"])).length if dist < 3: nearby.append(other["name"]) if nearby: groups.append(f"{obj['name']} near {nearby[0]}") return { "object_count": len(bpy.data.objects), "main_objects": objects[:10], "spatial_groups": groups[:5], "viewport_center": [0, 0, 0] } @thread_safe def verify_last_operation( expected_result: Dict[str, Any], capture_image: bool = True ) -> Dict[str, Any]: """ Verify last operation for VLM feedback loop. """ verification = { "success": True, "issues": [], "suggestions": [] } # Check if expected object exists if "object_name" in expected_result: obj = bpy.data.objects.get(expected_result["object_name"]) if not obj: verification["success"] = False verification["issues"].append("Object not found") return verification # Check position if specified if "expected_location" in expected_result: expected = mathutils.Vector(expected_result["expected_location"]) actual = obj.location distance = (expected - actual).length if distance > 0.5: verification["issues"].append(f"Position off by {distance:.2f}") verification["suggestions"].append(f"Move to {list(expected)}") # Capture viewport if requested if capture_image: verification["viewport"] = capture_viewport_image(resolution=(256, 256)) # Add spatial context verification["spatial_context"] = analyze_spatial_layout() return verification @thread_safe def auto_arrange_objects( strategy: str = "grid", spacing: float = 2.0 ) -> Dict[str, Any]: """ Auto-arrange objects for better VLM visibility. """ objects = [o for o in bpy.data.objects if o.type == 'MESH'] if strategy == "grid": cols = int(math.sqrt(len(objects))) for i, obj in enumerate(objects): row = i // cols col = i % cols obj.location = [col * spacing, -row * spacing, 0] elif strategy == "circle": angle_step = 360 / len(objects) if objects else 1 radius = spacing * len(objects) / (2 * math.pi) for i, obj in enumerate(objects): angle = math.radians(i * angle_step) obj.location = [ math.cos(angle) * radius, math.sin(angle) * radius, 0 ] elif strategy == "line": for i, obj in enumerate(objects): obj.location = [i * spacing, 0, 0] return { "arranged": len(objects), "strategy": strategy, "spacing": spacing } @thread_safe def set_optimal_camera_for_all() -> Dict[str, Any]: """ Position camera to see all objects (for VLM). """ if not bpy.data.objects: return {"error": "No objects in scene"} # Calculate bounding box of all objects min_co = [float('inf')] * 3 max_co = [float('-inf')] * 3 for obj in bpy.data.objects: if obj.type in ['MESH', 'CURVE', 'FONT']: for corner in obj.bound_box: world_co = obj.matrix_world @ mathutils.Vector(corner) for i in range(3): min_co[i] = min(min_co[i], world_co[i]) max_co[i] = max(max_co[i], world_co[i]) center = [(min_co[i] + max_co[i]) / 2 for i in range(3)] size = [max_co[i] - min_co[i] for i in range(3)] distance = max(size) * 2 # Create or get camera if not bpy.context.scene.camera: cam_data = bpy.data.cameras.new("VLM_Camera") cam_obj = bpy.data.objects.new("VLM_Camera", cam_data) bpy.context.collection.objects.link(cam_obj) bpy.context.scene.camera = cam_obj else: cam_obj = bpy.context.scene.camera # Position camera cam_obj.location = [ center[0] + distance * 0.7, center[1] - distance, center[2] + distance * 0.5 ] # Point at center direction = mathutils.Vector(center) - cam_obj.location cam_obj.rotation_euler = direction.to_track_quat('-Z', 'Y').to_euler() return { "camera": cam_obj.name, "target_center": center, "view_distance": distance } # ============================================ # ๐ŸŽ† PARTICLE SYSTEMS & PHYSICS # ============================================ @thread_safe def create_particle_system( object_name: str, particle_type: str = "HAIR", count: int = 1000, physics_type: str = "NEWTONIAN", emit_from: str = "FACE", lifetime: int = 50, gravity: float = 1.0, size: float = 0.05, random_size: float = 0.0, velocity_normal: float = 1.0, velocity_random: float = 0.5, hair_length: float = 0.2, hair_segments: int = 5, brownian_factor: float = 0.0, damping: float = 0.0, use_children: bool = False, child_count: int = 10, render_type: str = "PATH", material_slot: int = 1 ) -> Dict[str, Any]: """ Create advanced particle systems for hair, smoke, and effects. Args: object_name: Emitter object particle_type: HAIR or EMITTER count: Number of particles physics_type: NEWTONIAN, KEYED, BOIDS, FLUID, NO emit_from: VERT, FACE, VOLUME lifetime: Particle lifetime in frames gravity: Gravity influence size: Particle size random_size: Size randomization velocity_normal: Normal velocity velocity_random: Random velocity hair_length: Length for hair particles hair_segments: Segments for hair brownian_factor: Brownian motion damping: Velocity damping use_children: Enable child particles child_count: Number of children per particle render_type: PATH, OBJECT, COLLECTION, NONE material_slot: Material slot for particles Returns: Complete particle system configuration """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Add particle system modifier particle_mod = obj.modifiers.new(name="ParticleSystem", type='PARTICLE_SYSTEM') particle_system = obj.particle_systems[particle_mod.name] settings = particle_system.settings # Basic settings settings.type = particle_type settings.count = count settings.emit_from = emit_from settings.lifetime = lifetime settings.particle_size = size settings.size_random = random_size # Physics settings settings.physics_type = physics_type if physics_type == "NEWTONIAN": settings.mass = 1.0 settings.effector_weights.gravity = gravity settings.normal_factor = velocity_normal settings.factor_random = velocity_random settings.brownian_factor = brownian_factor settings.damping = damping elif physics_type == "BOIDS": boids = settings.boids boids.use_flight = True boids.use_land = False boids.use_climb = False boids.air_speed_max = 10.0 boids.air_speed_min = 5.0 boids.air_acc_max = 0.5 boids.bank = 1.0 boids.pitch = 1.0 # Add default boid rules rule = boids.states[0].rules.new(type='SEPARATE') rule.distance = 2.0 rule = boids.states[0].rules.new(type='FLOCK') rule.distance = 10.0 rule = boids.states[0].rules.new(type='FOLLOW_LEADER') rule.distance = 5.0 elif physics_type == "FLUID": fluid = settings.fluid fluid.solver = 'DDR' fluid.stiffness = 1.0 fluid.linear_viscosity = 1.0 fluid.use_initial_rest_length = True # Hair specific settings if particle_type == "HAIR": settings.hair_length = hair_length settings.hair_step = hair_segments settings.root_radius = 1.0 settings.tip_radius = 0.0 # Children if use_children: settings.child_type = 'INTERPOLATED' if particle_type == "HAIR" else 'SIMPLE' settings.rendered_child_count = child_count settings.child_radius = 0.2 settings.child_roundness = 1.0 # Render settings settings.render_type = render_type settings.material_slot = material_slot if render_type == "PATH": settings.render_step = 3 settings.path_start = 0.0 settings.path_end = 1.0 return { "object": object_name, "particle_system": particle_mod.name, "type": particle_type, "physics": physics_type, "count": count, "lifetime": lifetime, "emit_from": emit_from, "size": size, "children": { "enabled": use_children, "count": child_count if use_children else 0 }, "render_type": render_type, "created_at": datetime.now().isoformat() } @thread_safe def add_force_field( field_type: str = "FORCE", strength: float = 1.0, location: Optional[List[float]] = None, flow: float = 0.0, noise: float = 0.0, falloff_type: str = "SPHERE", falloff_power: float = 2.0, max_distance: float = 0.0, min_distance: float = 0.0, use_radial_min: bool = False, use_radial_max: bool = True, name: Optional[str] = None ) -> Dict[str, Any]: """ Add force fields for particle and physics simulations. Args: field_type: FORCE, WIND, VORTEX, MAGNETIC, HARMONIC, CHARGE, LENNARDJ, TEXTURE, GUIDE, BOID, TURBULENCE, DRAG, SMOKE strength: Field strength location: Field position flow: Inflow/outflow for fluid noise: Noise amount falloff_type: SPHERE, TUBE, CONE falloff_power: Power of falloff max_distance: Maximum effect distance min_distance: Minimum effect distance use_radial_min: Use minimum radial distance use_radial_max: Use maximum radial distance name: Force field name Returns: Force field configuration """ location = location or [0, 0, 2] # Create empty for force field bpy.ops.object.empty_add(type='SPHERE', location=location) field_obj = bpy.context.active_object field_obj.name = name or f"ForceField_{field_type}" # Add force field field_obj.field.type = field_type field_obj.field.strength = strength field_obj.field.flow = flow field_obj.field.noise = noise field_obj.field.falloff_type = falloff_type field_obj.field.falloff_power = falloff_power field_obj.field.distance_max = max_distance field_obj.field.distance_min = min_distance field_obj.field.use_radial_min = use_radial_min field_obj.field.use_radial_max = use_radial_max # Type-specific settings if field_type == "VORTEX": field_obj.field.inflow = 5.0 elif field_type == "TURBULENCE": field_obj.field.size = 1.0 field_obj.field.flow = 0.5 elif field_type == "HARMONIC": field_obj.field.harmonic_damping = 0.5 return { "name": field_obj.name, "type": field_type, "strength": strength, "location": list(field_obj.location), "falloff": { "type": falloff_type, "power": falloff_power, "max_distance": max_distance, "min_distance": min_distance }, "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ”ท GEOMETRY NODES # ============================================ @thread_safe def add_geometry_nodes( object_name: str, node_tree_name: Optional[str] = None, nodes_config: Optional[List[Dict]] = None ) -> Dict[str, Any]: """ Add and configure geometry nodes for procedural modeling. Args: object_name: Target object node_tree_name: Name for node tree nodes_config: List of node configurations Returns: Geometry nodes setup information """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Add geometry nodes modifier geo_mod = obj.modifiers.new(name="GeometryNodes", type='NODES') # Create new node tree if not node_tree_name: node_tree_name = f"GeometryNodes_{object_name}" node_tree = bpy.data.node_groups.new(name=node_tree_name, type='GeometryNodeTree') geo_mod.node_group = node_tree nodes = node_tree.nodes links = node_tree.links # Add input and output nodes input_node = nodes.new('NodeGroupInput') input_node.location = (-200, 0) output_node = nodes.new('NodeGroupOutput') output_node.location = (200, 0) # Create default socket if needed (Blender 3.0+) if hasattr(node_tree, 'inputs'): node_tree.inputs.new('NodeSocketGeometry', 'Geometry') if hasattr(node_tree, 'outputs'): node_tree.outputs.new('NodeSocketGeometry', 'Geometry') # Connect input to output by default links.new(input_node.outputs[0], output_node.inputs[0]) # Add custom nodes if configured created_nodes = [] if nodes_config: for config in nodes_config: node_type = config.get('type') node_pos = config.get('position', [0, 0]) node_settings = config.get('settings', {}) if node_type: new_node = nodes.new(node_type) new_node.location = node_pos # Apply settings for key, value in node_settings.items(): if hasattr(new_node, key): setattr(new_node, key, value) elif key in new_node.inputs: new_node.inputs[key].default_value = value created_nodes.append({ "type": node_type, "name": new_node.name, "position": list(new_node.location) }) return { "object": object_name, "modifier": geo_mod.name, "node_tree": node_tree.name, "nodes_created": len(nodes), "custom_nodes": created_nodes, "created_at": datetime.now().isoformat() } @thread_safe def create_procedural_geometry( object_name: str, geometry_type: str = "grid_scatter", parameters: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Create complex procedural geometry setups. Args: object_name: Target object geometry_type: Type of procedural setup parameters: Setup parameters Returns: Procedural geometry configuration """ params = parameters or {} obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Add geometry nodes geo_mod = obj.modifiers.new(name="ProceduralGeometry", type='NODES') node_tree = bpy.data.node_groups.new(name=f"Procedural_{geometry_type}", type='GeometryNodeTree') geo_mod.node_group = node_tree nodes = node_tree.nodes links = node_tree.links # Clear default nodes.clear() # Input/Output input_node = nodes.new('NodeGroupInput') input_node.location = (-400, 0) output_node = nodes.new('NodeGroupOutput') output_node.location = (400, 0) # Create sockets if hasattr(node_tree, 'inputs'): node_tree.inputs.new('NodeSocketGeometry', 'Geometry') if hasattr(node_tree, 'outputs'): node_tree.outputs.new('NodeSocketGeometry', 'Geometry') if geometry_type == "grid_scatter": # Create grid scatter setup distribute = nodes.new('GeometryNodeDistributePointsOnFaces') distribute.location = (-200, 0) distribute.inputs['Density'].default_value = params.get('density', 50.0) instance = nodes.new('GeometryNodeInstanceOnPoints') instance.location = (0, 0) cube = nodes.new('GeometryNodeMeshCube') cube.location = (-200, -150) cube.inputs['Size'].default_value = params.get('instance_size', 0.1) random_scale = nodes.new('GeometryNodeRandomValue') random_scale.location = (-200, -300) random_scale.data_type = 'FLOAT_VECTOR' random_scale.inputs[0].default_value = params.get('scale_min', [0.5, 0.5, 0.5]) random_scale.inputs[1].default_value = params.get('scale_max', [1.5, 1.5, 1.5]) links.new(input_node.outputs[0], distribute.inputs['Mesh']) links.new(distribute.outputs['Points'], instance.inputs['Points']) links.new(cube.outputs['Mesh'], instance.inputs['Instance']) links.new(random_scale.outputs['Value'], instance.inputs['Scale']) links.new(instance.outputs['Instances'], output_node.inputs[0]) elif geometry_type == "array_circular": # Circular array curve_circle = nodes.new('GeometryNodeCurveCircle') curve_circle.location = (-200, 100) curve_circle.inputs['Radius'].default_value = params.get('radius', 2.0) curve_circle.inputs['Resolution'].default_value = params.get('count', 12) curve_to_points = nodes.new('GeometryNodeCurveToPoints') curve_to_points.location = (0, 100) curve_to_points.mode = 'COUNT' curve_to_points.inputs['Count'].default_value = params.get('count', 12) instance = nodes.new('GeometryNodeInstanceOnPoints') instance.location = (200, 0) links.new(curve_circle.outputs['Curve'], curve_to_points.inputs['Curve']) links.new(curve_to_points.outputs['Points'], instance.inputs['Points']) links.new(input_node.outputs[0], instance.inputs['Instance']) links.new(instance.outputs['Instances'], output_node.inputs[0]) elif geometry_type == "voronoi_fracture": # Voronoi fracture effect voronoi = nodes.new('ShaderNodeTexVoronoi') voronoi.location = (-200, -100) voronoi.voronoi_dimensions = '3D' voronoi.inputs['Scale'].default_value = params.get('scale', 5.0) distribute = nodes.new('GeometryNodeDistributePointsOnFaces') distribute.location = (0, 0) distribute.inputs['Density'].default_value = params.get('density', 100.0) links.new(input_node.outputs[0], distribute.inputs['Mesh']) links.new(distribute.outputs['Points'], output_node.inputs[0]) return { "object": object_name, "geometry_type": geometry_type, "node_tree": node_tree.name, "parameters": params, "node_count": len(nodes), "created_at": datetime.now().isoformat() } # ============================================ # ๐ŸŽจ UV MAPPING & TEXTURING # ============================================ @thread_safe def unwrap_uv( object_name: str, method: str = "SMART", margin: float = 0.001, angle_limit: float = 66.0, island_margin: float = 0.0, area_weight: float = 0.0, correct_aspect: bool = True, scale_to_bounds: bool = False ) -> Dict[str, Any]: """ UV unwrap object with various methods. Args: object_name: Object to unwrap method: SMART, CUBE, SPHERE, CYLINDER, PROJECT, FOLLOW_ACTIVE_QUADS margin: Margin between UV islands angle_limit: Angle limit for smart project (degrees) island_margin: Island margin for smart project area_weight: Area weight for smart project correct_aspect: Correct aspect ratio scale_to_bounds: Scale UVs to bounds Returns: UV unwrap information """ obj = bpy.data.objects.get(object_name) if not obj or obj.type != 'MESH': raise ValueError(f"Mesh object '{object_name}' not found") # Ensure object is selected and active bpy.context.view_layer.objects.active = obj obj.select_set(True) # Enter edit mode bpy.ops.object.mode_set(mode='EDIT') # Select all bpy.ops.mesh.select_all(action='SELECT') # Create UV layer if doesn't exist mesh = obj.data if not mesh.uv_layers: mesh.uv_layers.new(name="UVMap") # Apply unwrap method try: if method == "SMART": bpy.ops.uv.smart_project( angle_limit=math.radians(angle_limit), island_margin=island_margin, area_weight=area_weight, correct_aspect=correct_aspect, scale_to_bounds=scale_to_bounds ) elif method == "CUBE": bpy.ops.uv.cube_project( cube_size=1.0, correct_aspect=correct_aspect, clip_to_bounds=False, scale_to_bounds=scale_to_bounds ) elif method == "SPHERE": bpy.ops.uv.sphere_project( direction='VIEW_ON_EQUATOR', align='POLAR_ZX', correct_aspect=correct_aspect, clip_to_bounds=False, scale_to_bounds=scale_to_bounds ) elif method == "CYLINDER": bpy.ops.uv.cylinder_project( direction='VIEW_ON_EQUATOR', align='POLAR_ZX', radius=1.0, correct_aspect=correct_aspect, clip_to_bounds=False, scale_to_bounds=scale_to_bounds ) elif method == "PROJECT": bpy.ops.uv.project_from_view( camera_bounds=False, correct_aspect=correct_aspect, scale_to_bounds=scale_to_bounds ) elif method == "FOLLOW_ACTIVE_QUADS": bpy.ops.uv.follow_active_quads(mode='LENGTH_AVERAGE') else: # Default unwrap bpy.ops.uv.unwrap( method='ANGLE_BASED', margin=margin ) finally: # Return to object mode bpy.ops.object.mode_set(mode='OBJECT') # Calculate UV statistics uv_layer = mesh.uv_layers.active uv_data = uv_layer.data # Get UV bounds min_u = min_v = float('inf') max_u = max_v = float('-inf') for uv in uv_data: min_u = min(min_u, uv.uv[0]) max_u = max(max_u, uv.uv[0]) min_v = min(min_v, uv.uv[1]) max_v = max(max_v, uv.uv[1]) return { "object": object_name, "method": method, "uv_layer": uv_layer.name, "uv_bounds": { "min": [min_u, min_v], "max": [max_u, max_v], "size": [max_u - min_u, max_v - min_v] }, "settings": { "margin": margin, "angle_limit": angle_limit, "correct_aspect": correct_aspect, "scale_to_bounds": scale_to_bounds }, "face_count": len(mesh.polygons), "uv_count": len(uv_data), "created_at": datetime.now().isoformat() } @thread_safe def add_texture_paint_slots( object_name: str, texture_types: List[str], resolution: int = 2048, color_space: str = "sRGB", alpha: bool = False, float_buffer: bool = False, generated_type: str = "BLANK" ) -> Dict[str, Any]: """ Setup texture paint slots for PBR workflow. Args: object_name: Target object texture_types: List of texture types (BASE_COLOR, NORMAL, ROUGHNESS, METALLIC, etc.) resolution: Texture resolution color_space: Color space (sRGB, Non-Color, Linear) alpha: Include alpha channel float_buffer: Use 32-bit float generated_type: BLANK, UV_GRID, COLOR_GRID Returns: Texture slots configuration """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Ensure material exists if not obj.active_material: mat = bpy.data.materials.new(name=f"{object_name}_Material") obj.data.materials.append(mat) mat = obj.active_material mat.use_nodes = True nodes = mat.node_tree.nodes links = mat.node_tree.links # Find or create Principled BSDF bsdf = None for node in nodes: if node.type == 'BSDF_PRINCIPLED': bsdf = node break if not bsdf: bsdf = nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (0, 0) output = nodes.get('Material Output') if output: links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) created_textures = [] texture_nodes = {} for tex_type in texture_types: # Create image texture img_name = f"{object_name}_{tex_type}" img = bpy.data.images.new( name=img_name, width=resolution, height=resolution, alpha=alpha, float_buffer=float_buffer ) # Set color space if tex_type in ['NORMAL', 'ROUGHNESS', 'METALLIC', 'DISPLACEMENT']: img.colorspace_settings.name = 'Non-Color' else: img.colorspace_settings.name = color_space # Generate default content if generated_type == "UV_GRID": img.generated_type = 'UV_GRID' elif generated_type == "COLOR_GRID": img.generated_type = 'COLOR_GRID' else: img.generated_type = 'BLANK' if tex_type == 'BASE_COLOR': img.generated_color = (0.5, 0.5, 0.5, 1.0) elif tex_type == 'ROUGHNESS': img.generated_color = (0.5, 0.5, 0.5, 1.0) elif tex_type == 'METALLIC': img.generated_color = (0.0, 0.0, 0.0, 1.0) elif tex_type == 'NORMAL': img.generated_color = (0.5, 0.5, 1.0, 1.0) # Create texture node tex_node = nodes.new('ShaderNodeTexImage') tex_node.image = img tex_node.location = (-300, len(texture_nodes) * -150) texture_nodes[tex_type] = tex_node # Connect to BSDF if tex_type == 'BASE_COLOR' and 'Base Color' in bsdf.inputs: links.new(tex_node.outputs['Color'], bsdf.inputs['Base Color']) elif tex_type == 'ROUGHNESS' and 'Roughness' in bsdf.inputs: links.new(tex_node.outputs['Color'], bsdf.inputs['Roughness']) elif tex_type == 'METALLIC' and 'Metallic' in bsdf.inputs: links.new(tex_node.outputs['Color'], bsdf.inputs['Metallic']) elif tex_type == 'NORMAL' and 'Normal' in bsdf.inputs: # Add normal map node normal_map = nodes.new('ShaderNodeNormalMap') normal_map.location = (-150, len(texture_nodes) * -150) links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) links.new(normal_map.outputs['Normal'], bsdf.inputs['Normal']) created_textures.append({ "name": img_name, "type": tex_type, "resolution": f"{resolution}x{resolution}", "color_space": img.colorspace_settings.name }) return { "object": object_name, "material": mat.name, "textures_created": created_textures, "texture_count": len(created_textures), "resolution": resolution, "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿš€ BATCH OPERATIONS # ============================================ @thread_safe def batch_create_objects( operations: List[Dict[str, Any]], group_name: Optional[str] = None, parent_to: Optional[str] = None ) -> Dict[str, Any]: """ Execute multiple object creations efficiently in batch. Args: operations: List of operation dictionaries with 'type' and parameters group_name: Optional collection name to group objects parent_to: Optional parent object name Returns: Batch operation results """ created_objects = [] errors = [] # Create collection if specified collection = None if group_name: collection = bpy.data.collections.get(group_name) if not collection: collection = bpy.data.collections.new(group_name) bpy.context.scene.collection.children.link(collection) # Get parent object if specified parent_obj = None if parent_to: parent_obj = bpy.data.objects.get(parent_to) # Process operations for i, op in enumerate(operations): try: op_type = op.get('type') params = op.get('parameters', {}) # Execute based on operation type if op_type == 'mesh': result = create_mesh_object(**params) elif op_type == 'curve': result = create_curve_object(**params) elif op_type == 'text': result = create_text_object(**params) elif op_type == 'light': result = create_light(**params) elif op_type == 'camera': result = create_camera(**params) elif op_type == 'empty': bpy.ops.object.empty_add( type=params.get('display_type', 'PLAIN_AXES'), location=params.get('location', [0, 0, 0]) ) result = {"name": bpy.context.active_object.name, "type": "empty"} else: raise ValueError(f"Unknown operation type: {op_type}") # Handle collection and parenting if result and 'name' in result: obj = bpy.data.objects.get(result['name']) if obj and collection: # Move to collection for coll in obj.users_collection: coll.objects.unlink(obj) collection.objects.link(obj) if obj and parent_obj: obj.parent = parent_obj obj.matrix_parent_inverse = parent_obj.matrix_world.inverted() created_objects.append({ "index": i, "type": op_type, "name": result.get('name'), "success": True }) except Exception as e: errors.append({ "index": i, "type": op.get('type'), "error": str(e) }) return { "total_operations": len(operations), "successful": len(created_objects), "failed": len(errors), "created_objects": created_objects, "errors": errors, "collection": group_name, "parent": parent_to, "created_at": datetime.now().isoformat() } @thread_safe def batch_transform( objects: List[str], transformations: Dict[str, Any] ) -> Dict[str, Any]: """ Apply transformations to multiple objects at once. Args: objects: List of object names transformations: Dictionary with location, rotation, scale operations Returns: Batch transformation results """ results = [] location = transformations.get('location') rotation = transformations.get('rotation') scale = transformations.get('scale') delta_mode = transformations.get('delta', False) randomize = transformations.get('randomize', {}) for obj_name in objects: obj = bpy.data.objects.get(obj_name) if not obj: results.append({"object": obj_name, "success": False, "error": "Not found"}) continue try: # Apply location if location: loc = location.copy() if randomize.get('location'): rand_range = randomize['location'] loc = [loc[i] + random.uniform(-rand_range, rand_range) for i in range(3)] if delta_mode: obj.location = [obj.location[i] + loc[i] for i in range(3)] else: obj.location = loc # Apply rotation if rotation: rot = rotation.copy() if randomize.get('rotation'): rand_range = randomize['rotation'] rot = [rot[i] + random.uniform(-rand_range, rand_range) for i in range(3)] rot_rad = [math.radians(r) for r in rot] if delta_mode: obj.rotation_euler = [obj.rotation_euler[i] + rot_rad[i] for i in range(3)] else: obj.rotation_euler = rot_rad # Apply scale if scale: scl = scale.copy() if isinstance(scale, list) else [scale, scale, scale] if randomize.get('scale'): rand_range = randomize['scale'] scl = [scl[i] * random.uniform(1-rand_range, 1+rand_range) for i in range(3)] if delta_mode: obj.scale = [obj.scale[i] * scl[i] for i in range(3)] else: obj.scale = scl results.append({ "object": obj_name, "success": True, "new_location": list(obj.location), "new_rotation": [math.degrees(r) for r in obj.rotation_euler], "new_scale": list(obj.scale) }) except Exception as e: results.append({"object": obj_name, "success": False, "error": str(e)}) return { "objects_processed": len(objects), "successful": len([r for r in results if r["success"]]), "delta_mode": delta_mode, "randomization": bool(randomize), "results": results, "processed_at": datetime.now().isoformat() } # ============================================ # โš™๏ธ SIMULATION SYSTEMS # ============================================ @thread_safe def setup_rigid_body( object_name: str, body_type: str = "ACTIVE", shape: str = "CONVEX_HULL", mass: float = 1.0, friction: float = 0.5, bounciness: float = 0.0, use_margin: bool = False, collision_margin: float = 0.04, linear_damping: float = 0.04, angular_damping: float = 0.1, collision_collections: Optional[List[int]] = None ) -> Dict[str, Any]: """ Setup rigid body physics simulation. Args: object_name: Object to make rigid body body_type: ACTIVE or PASSIVE shape: BOX, SPHERE, CAPSULE, CYLINDER, CONE, CONVEX_HULL, MESH, COMPOUND mass: Object mass friction: Surface friction bounciness: Restitution/bounciness use_margin: Use collision margin collision_margin: Margin size linear_damping: Linear motion damping angular_damping: Angular motion damping collision_collections: Collision layers (1-20) Returns: Rigid body configuration """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Enable rigid body bpy.context.view_layer.objects.active = obj bpy.ops.rigidbody.object_add() rb = obj.rigid_body # Configure rigid body rb.type = body_type rb.collision_shape = shape rb.mass = mass rb.friction = friction rb.restitution = bounciness rb.use_margin = use_margin rb.collision_margin = collision_margin rb.linear_damping = linear_damping rb.angular_damping = angular_damping # Set collision collections if collision_collections: for i in range(20): rb.collision_collections[i] = (i + 1) in collision_collections # Calculate physics properties volume = 0 if hasattr(obj.data, 'vertices'): # Estimate volume from bounding box dims = obj.dimensions volume = dims[0] * dims[1] * dims[2] density = mass / volume if volume > 0 else 1.0 return { "object": object_name, "rigid_body": { "type": body_type, "shape": shape, "mass": mass, "friction": friction, "bounciness": bounciness, "linear_damping": linear_damping, "angular_damping": angular_damping }, "physics": { "estimated_volume": volume, "density": density, "collision_margin": collision_margin if use_margin else None }, "created_at": datetime.now().isoformat() } @thread_safe def add_cloth_simulation( object_name: str, quality: int = 5, mass: float = 0.3, tension_stiffness: float = 15.0, compression_stiffness: float = 15.0, shear_stiffness: float = 5.0, bending_stiffness: float = 0.5, damping_tension: float = 5.0, damping_compression: float = 5.0, damping_shear: float = 5.0, damping_bending: float = 0.5, air_damping: float = 1.0, use_collision: bool = True, collision_distance: float = 0.015, use_self_collision: bool = False, self_collision_distance: float = 0.015, vertex_group_mass: Optional[str] = None, pin_vertex_group: Optional[str] = None ) -> Dict[str, Any]: """ Add realistic cloth simulation. Args: object_name: Object to make cloth quality: Simulation quality steps mass: Cloth mass (kg/mยฒ) tension_stiffness: Tension spring stiffness compression_stiffness: Compression spring stiffness shear_stiffness: Shear spring stiffness bending_stiffness: Bending spring stiffness damping_tension: Tension damping damping_compression: Compression damping damping_shear: Shear damping damping_bending: Bending damping air_damping: Air resistance use_collision: Enable collision collision_distance: Collision distance use_self_collision: Enable self collision self_collision_distance: Self collision distance vertex_group_mass: Vertex group for mass variation pin_vertex_group: Vertex group for pinning Returns: Cloth simulation configuration """ obj = bpy.data.objects.get(object_name) if not obj or obj.type != 'MESH': raise ValueError(f"Mesh object '{object_name}' not found") # Add cloth modifier cloth_mod = obj.modifiers.new(name="Cloth", type='CLOTH') cloth = cloth_mod.settings # Quality settings cloth.quality = quality # Physical properties cloth.mass = mass cloth.air_damping = air_damping # Stiffness settings cloth.tension_stiffness = tension_stiffness cloth.compression_stiffness = compression_stiffness cloth.shear_stiffness = shear_stiffness cloth.bending_stiffness = bending_stiffness # Damping settings cloth.tension_damping = damping_tension cloth.compression_damping = damping_compression cloth.shear_damping = damping_shear cloth.bending_damping = damping_bending # Collision settings if use_collision: cloth.collision_settings.use_collision = True cloth.collision_settings.distance_min = collision_distance cloth.collision_settings.use_self_collision = use_self_collision if use_self_collision: cloth.collision_settings.self_distance_min = self_collision_distance # Vertex groups if pin_vertex_group: cloth.vertex_group_mass = pin_vertex_group if vertex_group_mass: cloth.vertex_group_mass = vertex_group_mass return { "object": object_name, "modifier": cloth_mod.name, "settings": { "quality": quality, "mass": mass, "air_damping": air_damping }, "stiffness": { "tension": tension_stiffness, "compression": compression_stiffness, "shear": shear_stiffness, "bending": bending_stiffness }, "damping": { "tension": damping_tension, "compression": damping_compression, "shear": damping_shear, "bending": damping_bending }, "collision": { "enabled": use_collision, "distance": collision_distance, "self_collision": use_self_collision }, "created_at": datetime.now().isoformat() } @thread_safe def setup_fluid_simulation( domain_name: str, resolution: int = 64, simulation_type: str = "LIQUID", time_scale: float = 1.0, use_adaptive_domain: bool = True, use_collision_objects: bool = True, viscosity: float = 0.0, surface_tension: float = 0.0, use_foam: bool = False, use_spray: bool = False, use_bubbles: bool = False ) -> Dict[str, Any]: """ Setup complete fluid simulation domain. Args: domain_name: Domain object name resolution: Simulation resolution simulation_type: LIQUID or GAS time_scale: Time scale factor use_adaptive_domain: Adaptive domain sizing use_collision_objects: Enable collisions viscosity: Fluid viscosity surface_tension: Surface tension use_foam: Generate foam particles use_spray: Generate spray particles use_bubbles: Generate bubble particles Returns: Fluid simulation configuration """ obj = bpy.data.objects.get(domain_name) if not obj: raise ValueError(f"Object '{domain_name}' not found") # Add fluid modifier fluid_mod = obj.modifiers.new(name="Fluid", type='FLUID') fluid_mod.fluid_type = 'DOMAIN' domain = fluid_mod.domain_settings # Basic settings domain.domain_type = simulation_type domain.resolution_max = resolution domain.time_scale = time_scale domain.use_adaptive_domain = use_adaptive_domain domain.use_collision_objects = use_collision_objects # Liquid specific settings if simulation_type == "LIQUID": domain.use_flip_particles = True domain.flip_ratio = 0.97 domain.particle_radius = 1.5 # Viscosity if viscosity > 0: domain.use_viscosity = True domain.viscosity_value = viscosity # Surface tension if surface_tension > 0: domain.use_surface_tension = True domain.surface_tension = surface_tension # Secondary particles if use_foam: domain.use_foam_particles = True domain.foam_lifetime = 2.0 if use_spray: domain.use_spray_particles = True domain.spray_lifetime = 1.5 if use_bubbles: domain.use_bubble_particles = True domain.bubble_lifetime = 1.0 # Gas specific settings elif simulation_type == "GAS": domain.use_noise = True domain.noise_scale = 2.0 domain.noise_strength = 1.0 # Cache settings domain.cache_type = 'MODULAR' domain.cache_directory = f"//cache_fluid_{domain_name}" return { "domain": domain_name, "type": simulation_type, "resolution": resolution, "settings": { "time_scale": time_scale, "adaptive_domain": use_adaptive_domain, "collision_objects": use_collision_objects, "viscosity": viscosity, "surface_tension": surface_tension }, "particles": { "foam": use_foam, "spray": use_spray, "bubbles": use_bubbles }, "cache_directory": domain.cache_directory, "created_at": datetime.now().isoformat() } @thread_safe def add_fluid_flow( object_name: str, flow_type: str = "INFLOW", flow_behavior: str = "INFLOW", flow_rate: float = 1.0, temperature: float = 1.0, density: float = 1.0, fuel_amount: float = 1.0, smoke_color: Optional[List[float]] = None, use_texture: bool = False, texture_size: float = 1.0, texture_offset: Optional[List[float]] = None ) -> Dict[str, Any]: """ Add fluid flow source/obstacle. Args: object_name: Flow object flow_type: INFLOW, OUTFLOW, SMOKE, FIRE, LIQUID flow_behavior: INFLOW, OUTFLOW, GEOMETRY flow_rate: Flow emission rate temperature: Temperature differential density: Smoke density fuel_amount: Fuel for fire smoke_color: Smoke color use_texture: Use texture for flow texture_size: Texture scale texture_offset: Texture offset Returns: Flow configuration """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") smoke_color = smoke_color or [0.7, 0.7, 0.7] texture_offset = texture_offset or [0, 0, 0] # Add fluid modifier fluid_mod = obj.modifiers.new(name="Fluid", type='FLUID') fluid_mod.fluid_type = 'FLOW' flow = fluid_mod.flow_settings # Configure flow flow.flow_type = flow_type flow.flow_behavior = flow_behavior flow.subframes = 1 # Set flow properties based on type if flow_type in ['SMOKE', 'FIRE', 'BOTH']: flow.smoke_color = smoke_color flow.temperature = temperature flow.density = density if flow_type in ['FIRE', 'BOTH']: flow.fuel_amount = fuel_amount elif flow_type == 'LIQUID': flow.use_initial_velocity = True flow.velocity_factor = flow_rate # Texture settings if use_texture: flow.use_texture = True flow.texture_size = texture_size flow.texture_offset = texture_offset return { "object": object_name, "flow_type": flow_type, "flow_behavior": flow_behavior, "properties": { "temperature": temperature, "density": density, "fuel_amount": fuel_amount if flow_type in ['FIRE', 'BOTH'] else None, "smoke_color": smoke_color }, "texture": { "enabled": use_texture, "size": texture_size, "offset": texture_offset }, "created_at": datetime.now().isoformat() } # ============================================ # ๐ŸŽญ ADVANCED NODE EDITOR # ============================================ @thread_safe def create_shader_node_tree( material_name: str, nodes: List[Dict[str, Any]], connections: List[Tuple[str, str, str, str]] ) -> Dict[str, Any]: """ Create complex shader networks programmatically. Args: material_name: Material to modify nodes: List of node definitions with type, name, location, settings connections: List of (from_node, from_socket, to_node, to_socket) Returns: Shader network configuration """ mat = bpy.data.materials.get(material_name) if not mat: mat = bpy.data.materials.new(name=material_name) mat.use_nodes = True node_tree = mat.node_tree tree_nodes = node_tree.nodes links = node_tree.links # Clear existing nodes if requested created_nodes = {} # Create nodes for node_def in nodes: node_type = node_def.get('type') node_name = node_def.get('name', node_type) location = node_def.get('location', [0, 0]) settings = node_def.get('settings', {}) # Create node new_node = tree_nodes.new(node_type) new_node.name = node_name new_node.location = location # Apply settings for key, value in settings.items(): if hasattr(new_node, key): setattr(new_node, key, value) elif key in new_node.inputs: new_node.inputs[key].default_value = value created_nodes[node_name] = new_node # Create connections for from_node, from_socket, to_node, to_socket in connections: if from_node in created_nodes and to_node in created_nodes: from_n = created_nodes[from_node] to_n = created_nodes[to_node] # Find sockets from_s = None to_s = None if from_socket in from_n.outputs: from_s = from_n.outputs[from_socket] elif from_socket.isdigit(): from_s = from_n.outputs[int(from_socket)] if to_socket in to_n.inputs: to_s = to_n.inputs[to_socket] elif to_socket.isdigit(): to_s = to_n.inputs[int(to_socket)] if from_s and to_s: links.new(from_s, to_s) return { "material": material_name, "nodes_created": len(created_nodes), "connections_made": len(connections), "node_tree": node_tree.name, "nodes": list(created_nodes.keys()), "created_at": datetime.now().isoformat() } @thread_safe def create_procedural_material( name: str, material_type: str = "wood", parameters: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Create procedural materials with customizable parameters. Args: name: Material name material_type: wood, marble, concrete, metal, fabric, glass, etc. parameters: Type-specific parameters Returns: Procedural material configuration """ params = parameters or {} # Create material mat = bpy.data.materials.new(name=name) mat.use_nodes = True nodes = mat.node_tree.nodes links = mat.node_tree.links # Clear default nodes nodes.clear() # Add output node output = nodes.new('ShaderNodeOutputMaterial') output.location = (800, 0) # Create material based on type if material_type == "wood": # Wood procedural material coord = nodes.new('ShaderNodeTexCoord') coord.location = (-800, 0) mapping = nodes.new('ShaderNodeMapping') mapping.location = (-600, 0) mapping.inputs['Scale'].default_value = params.get('scale', [1, 1, 20]) wave1 = nodes.new('ShaderNodeTexWave') wave1.location = (-400, 100) wave1.wave_type = 'RINGS' wave1.inputs['Scale'].default_value = params.get('ring_scale', 5.0) wave1.inputs['Distortion'].default_value = params.get('distortion', 2.0) noise = nodes.new('ShaderNodeTexNoise') noise.location = (-400, -100) noise.inputs['Scale'].default_value = params.get('noise_scale', 50.0) noise.inputs['Detail'].default_value = params.get('detail', 5.0) mix = nodes.new('ShaderNodeMixRGB') mix.location = (-200, 0) mix.blend_type = 'MIX' mix.inputs['Fac'].default_value = params.get('mix_factor', 0.2) colorramp = nodes.new('ShaderNodeValToRGB') colorramp.location = (0, 0) colorramp.color_ramp.elements[0].color = params.get('color1', [0.1, 0.05, 0.01, 1]) colorramp.color_ramp.elements[1].color = params.get('color2', [0.3, 0.15, 0.05, 1]) bsdf = nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (400, 0) bsdf.inputs['Roughness'].default_value = params.get('roughness', 0.8) # Connect nodes links.new(coord.outputs['Object'], mapping.inputs['Vector']) links.new(mapping.outputs['Vector'], wave1.inputs['Vector']) links.new(mapping.outputs['Vector'], noise.inputs['Vector']) links.new(wave1.outputs['Fac'], mix.inputs['Color1']) links.new(noise.outputs['Fac'], mix.inputs['Color2']) links.new(mix.outputs['Color'], colorramp.inputs['Fac']) links.new(colorramp.outputs['Color'], bsdf.inputs['Base Color']) links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) elif material_type == "marble": # Marble procedural material coord = nodes.new('ShaderNodeTexCoord') coord.location = (-800, 0) noise1 = nodes.new('ShaderNodeTexNoise') noise1.location = (-400, 100) noise1.inputs['Scale'].default_value = params.get('scale', 2.0) noise1.inputs['Detail'].default_value = params.get('detail', 5.0) noise1.inputs['Distortion'].default_value = params.get('distortion', 2.0) voronoi = nodes.new('ShaderNodeTexVoronoi') voronoi.location = (-400, -100) voronoi.voronoi_dimensions = '3D' voronoi.feature = 'F1' voronoi.inputs['Scale'].default_value = params.get('vein_scale', 5.0) mix = nodes.new('ShaderNodeMixRGB') mix.location = (-200, 0) mix.blend_type = 'LINEAR_LIGHT' mix.inputs['Fac'].default_value = params.get('mix', 0.5) colorramp = nodes.new('ShaderNodeValToRGB') colorramp.location = (0, 0) colorramp.color_ramp.elements[0].color = params.get('color1', [0.8, 0.8, 0.8, 1]) colorramp.color_ramp.elements[1].color = params.get('color2', [0.2, 0.2, 0.25, 1]) bsdf = nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (400, 0) bsdf.inputs['Roughness'].default_value = params.get('roughness', 0.1) bsdf.inputs['IOR'].default_value = params.get('ior', 1.46) # Connect nodes links.new(coord.outputs['Object'], noise1.inputs['Vector']) links.new(coord.outputs['Object'], voronoi.inputs['Vector']) links.new(noise1.outputs['Fac'], mix.inputs['Color1']) links.new(voronoi.outputs['Distance'], mix.inputs['Color2']) links.new(mix.outputs['Color'], colorramp.inputs['Fac']) links.new(colorramp.outputs['Color'], bsdf.inputs['Base Color']) links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) elif material_type == "concrete": # Concrete procedural material coord = nodes.new('ShaderNodeTexCoord') coord.location = (-600, 0) noise = nodes.new('ShaderNodeTexNoise') noise.location = (-400, 0) noise.inputs['Scale'].default_value = params.get('scale', 10.0) noise.inputs['Detail'].default_value = params.get('detail', 15.0) noise.inputs['Roughness'].default_value = params.get('roughness', 0.9) musgrave = nodes.new('ShaderNodeTexMusgrave') musgrave.location = (-200, -100) musgrave.musgrave_type = 'FBM' musgrave.inputs['Scale'].default_value = params.get('texture_scale', 50.0) bump = nodes.new('ShaderNodeBump') bump.location = (0, -100) bump.inputs['Strength'].default_value = params.get('bump_strength', 0.1) bsdf = nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (200, 0) bsdf.inputs['Base Color'].default_value = params.get('color', [0.5, 0.5, 0.5, 1]) bsdf.inputs['Roughness'].default_value = params.get('surface_roughness', 0.9) # Connect nodes links.new(coord.outputs['Object'], noise.inputs['Vector']) links.new(coord.outputs['Object'], musgrave.inputs['Vector']) links.new(musgrave.outputs['Fac'], bump.inputs['Height']) links.new(bump.outputs['Normal'], bsdf.inputs['Normal']) links.new(noise.outputs['Fac'], bsdf.inputs['Roughness']) links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) else: # Default principled material bsdf = nodes.new('ShaderNodeBsdfPrincipled') bsdf.location = (0, 0) bsdf.inputs['Base Color'].default_value = params.get('color', [0.5, 0.5, 0.5, 1]) bsdf.inputs['Roughness'].default_value = params.get('roughness', 0.5) links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) return { "material": mat.name, "type": material_type, "parameters": params, "node_count": len(nodes), "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ—๏ธ TEMPLATE SYSTEM # ============================================ @thread_safe def create_from_template( template_name: str, parameters: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Create complex objects from predefined templates. Args: template_name: Template type (character, vehicle, architecture, tree, etc.) parameters: Template-specific parameters Returns: Template creation results """ params = parameters or {} results = {} if template_name == "character": # Create character with armature height = params.get('height', 1.8) # Create body parts # Head head = create_mesh_object( primitive_type="sphere", size=height * 0.15, location=[0, 0, height * 0.9], name="Character_Head" ) # Body body = create_mesh_object( primitive_type="cube", size=height * 0.3, location=[0, 0, height * 0.5], scale=[1, 0.5, 1.5], name="Character_Body" ) # Arms arm_l = create_mesh_object( primitive_type="cylinder", size=height * 0.3, location=[-height * 0.2, 0, height * 0.6], rotation=[0, 90, 0], scale=[0.3, 0.3, 1], name="Character_Arm_L" ) arm_r = create_mesh_object( primitive_type="cylinder", size=height * 0.3, location=[height * 0.2, 0, height * 0.6], rotation=[0, 90, 0], scale=[0.3, 0.3, 1], name="Character_Arm_R" ) # Legs leg_l = create_mesh_object( primitive_type="cylinder", size=height * 0.4, location=[-height * 0.08, 0, height * 0.2], scale=[0.3, 0.3, 1], name="Character_Leg_L" ) leg_r = create_mesh_object( primitive_type="cylinder", size=height * 0.4, location=[height * 0.08, 0, height * 0.2], scale=[0.3, 0.3, 1], name="Character_Leg_R" ) # Create armature if requested if params.get('add_armature', True): bpy.ops.object.armature_add(location=[0, 0, 0]) armature = bpy.context.active_object armature.name = "Character_Armature" # Setup basic bones in edit mode bpy.ops.object.mode_set(mode='EDIT') bones = armature.data.edit_bones # Create spine spine = bones.new('Spine') spine.head = [0, 0, height * 0.3] spine.tail = [0, 0, height * 0.7] # Create head bone head_bone = bones.new('Head') head_bone.head = spine.tail head_bone.tail = [0, 0, height] head_bone.parent = spine bpy.ops.object.mode_set(mode='OBJECT') results = { "template": "character", "objects_created": 6, "height": height, "armature": params.get('add_armature', True) } elif template_name == "vehicle": # Create basic vehicle length = params.get('length', 4) width = params.get('width', 2) height = params.get('height', 1.5) # Body body = create_mesh_object( primitive_type="cube", size=1, scale=[length, width, height], location=[0, 0, height * 0.7], name="Vehicle_Body" ) # Wheels wheel_positions = [ [-length * 0.35, -width * 0.5, height * 0.3], [length * 0.35, -width * 0.5, height * 0.3], [-length * 0.35, width * 0.5, height * 0.3], [length * 0.35, width * 0.5, height * 0.3] ] for i, pos in enumerate(wheel_positions): wheel = create_mesh_object( primitive_type="cylinder", size=height * 0.4, location=pos, rotation=[90, 0, 0], name=f"Vehicle_Wheel_{i+1}" ) results = { "template": "vehicle", "objects_created": 5, "dimensions": [length, width, height] } elif template_name == "building": # Create parametric building floors = params.get('floors', 5) width = params.get('width', 10) depth = params.get('depth', 10) floor_height = params.get('floor_height', 3) # Base structure building = create_mesh_object( primitive_type="cube", size=1, scale=[width, depth, floors * floor_height], location=[0, 0, floors * floor_height * 0.5], name="Building_Structure" ) # Add windows if requested if params.get('add_windows', True): window_mod = add_modifier( object_name="Building_Structure", modifier_type="ARRAY", settings={ 'count': floors, 'use_relative_offset': True, 'offset_z': 1.0 / floors } ) results = { "template": "building", "floors": floors, "dimensions": [width, depth, floors * floor_height] } elif template_name == "tree": # Create procedural tree trunk_height = params.get('trunk_height', 4) crown_size = params.get('crown_size', 3) # Trunk trunk = create_mesh_object( primitive_type="cylinder", size=trunk_height, location=[0, 0, trunk_height * 0.5], scale=[0.3, 0.3, 1], name="Tree_Trunk" ) # Crown crown = create_mesh_object( primitive_type="ico_sphere", size=crown_size, location=[0, 0, trunk_height + crown_size * 0.4], ico_subdivisions=2, name="Tree_Crown" ) # Add materials if params.get('add_materials', True): # Trunk material trunk_mat = create_material( name="Tree_Bark", base_color=[0.3, 0.2, 0.1, 1], roughness=0.9 ) assign_material("Tree_Trunk", "Tree_Bark") # Crown material crown_mat = create_material( name="Tree_Leaves", base_color=[0.1, 0.5, 0.1, 1], roughness=0.8 ) assign_material("Tree_Crown", "Tree_Leaves") results = { "template": "tree", "trunk_height": trunk_height, "crown_size": crown_size, "materials_added": params.get('add_materials', True) } else: raise ValueError(f"Unknown template: {template_name}") results["created_at"] = datetime.now().isoformat() return results # ============================================ # โœ๏ธ GREASE PENCIL # ============================================ @thread_safe def create_grease_pencil_drawing( name: str, strokes: List[Dict[str, Any]], materials: Optional[List[Dict[str, Any]]] = None, frame: int = 1 ) -> Dict[str, Any]: """ Create 2D drawings in 3D space using Grease Pencil. Args: name: Grease pencil object name strokes: List of stroke definitions materials: List of material definitions frame: Frame to draw on Returns: Grease pencil configuration """ # Create grease pencil data gp_data = bpy.data.grease_pencils.new(name) # Create grease pencil object gp_obj = bpy.data.objects.new(name, gp_data) bpy.context.collection.objects.link(gp_obj) # Create layer gp_layer = gp_data.layers.new('Drawing_Layer') # Create frame gp_frame = gp_layer.frames.new(frame) # Create materials created_materials = [] if materials: for mat_def in materials: mat = bpy.data.materials.new(mat_def.get('name', 'GP_Material')) mat.grease_pencil.show_stroke = mat_def.get('show_stroke', True) mat.grease_pencil.show_fill = mat_def.get('show_fill', False) mat.grease_pencil.color = mat_def.get('color', [0, 0, 0, 1]) gp_data.materials.append(mat) created_materials.append(mat.name) # Create strokes created_strokes = [] for stroke_def in strokes: stroke = gp_frame.strokes.new() # Set stroke properties stroke.line_width = stroke_def.get('line_width', 10) stroke.material_index = stroke_def.get('material_index', 0) stroke.use_cyclic = stroke_def.get('cyclic', False) # Add points points = stroke_def.get('points', [[0, 0, 0]]) stroke.points.add(len(points)) for i, point in enumerate(points): stroke.points[i].co = point stroke.points[i].pressure = stroke_def.get('pressure', 1.0) stroke.points[i].strength = stroke_def.get('strength', 1.0) created_strokes.append({ "point_count": len(points), "line_width": stroke.line_width, "cyclic": stroke.use_cyclic }) return { "name": gp_obj.name, "layers": 1, "frame": frame, "strokes_created": len(created_strokes), "materials_created": len(created_materials), "stroke_details": created_strokes, "created_at": datetime.now().isoformat() } # ============================================ # โšก PERFORMANCE & OPTIMIZATION # ============================================ @lru_cache(maxsize=Config.CACHE_SIZE) def get_cached_object_info(object_name: str) -> Optional[Dict[str, Any]]: """ Get cached object information for performance. Args: object_name: Object to query Returns: Cached object data or None """ obj = bpy.data.objects.get(object_name) if not obj: return None return { "name": obj.name, "type": obj.type, "location": list(obj.location), "rotation": [math.degrees(r) for r in obj.rotation_euler], "scale": list(obj.scale), "visible": obj.visible_get(), "selected": obj.select_get() } def clear_cache(): """Clear all caches""" get_cached_object_info.cache_clear() logger.info("Cache cleared") @thread_safe def optimize_scene( merge_objects: bool = False, remove_doubles: bool = True, decimate_ratio: float = 1.0, remove_unused_data: bool = True ) -> Dict[str, Any]: """ Optimize scene for better performance. Args: merge_objects: Merge selected objects remove_doubles: Remove duplicate vertices decimate_ratio: Decimation ratio (1.0 = no decimation) remove_unused_data: Remove unused data blocks Returns: Optimization results """ results = { "objects_processed": 0, "vertices_removed": 0, "unused_data_removed": 0 } # Process meshes for obj in bpy.data.objects: if obj.type == 'MESH': mesh = obj.data # Remove doubles if remove_doubles: bpy.context.view_layer.objects.active = obj bpy.ops.object.mode_set(mode='EDIT') bpy.ops.mesh.select_all(action='SELECT') before = len(mesh.vertices) bpy.ops.mesh.remove_doubles(threshold=0.0001) after = len(mesh.vertices) results["vertices_removed"] += before - after bpy.ops.object.mode_set(mode='OBJECT') # Add decimate if needed if decimate_ratio < 1.0: decimate = obj.modifiers.new('Decimate', 'DECIMATE') decimate.ratio = decimate_ratio results["objects_processed"] += 1 # Remove unused data if remove_unused_data: # Remove unused meshes for mesh in bpy.data.meshes: if mesh.users == 0: bpy.data.meshes.remove(mesh) results["unused_data_removed"] += 1 # Remove unused materials for mat in bpy.data.materials: if mat.users == 0: bpy.data.materials.remove(mat) results["unused_data_removed"] += 1 # Remove unused textures for img in bpy.data.images: if img.users == 0: bpy.data.images.remove(img) results["unused_data_removed"] += 1 results["optimized_at"] = datetime.now().isoformat() return results # ============================================ # ๐ŸŽฏ PRESETS & QUICK ACTIONS # ============================================ @thread_safe def quick_scene_setup( preset: str = "product_viz" ) -> Dict[str, Any]: """ Quick scene setups with optimal settings. Args: preset: Preset name (product_viz, architecture, character_animation, etc.) Returns: Scene setup results """ results = {} presets = { "product_viz": { "lighting": "three_point", "camera_settings": { "focal_length": 85, "location": [3, -3, 2], "rotation": [65, 0, 45] }, "render_settings": { "engine": "CYCLES", "samples": 256, "resolution_x": 1920, "resolution_y": 1080, "transparent": True }, "world_settings": { "use_hdri": False, "background_color": [0.05, 0.05, 0.05], "ambient_occlusion": True } }, "architecture": { "lighting": "sun_sky", "camera_settings": { "focal_length": 24, "location": [15, -15, 10], "rotation": [70, 0, 45] }, "render_settings": { "engine": "CYCLES", "samples": 512, "resolution_x": 3840, "resolution_y": 2160, "use_denoising": True }, "world_settings": { "use_hdri": True, "sun_rotation": 45 } }, "character_animation": { "lighting": "studio", "camera_settings": { "focal_length": 50, "location": [5, -5, 2], "rotation": [80, 0, 45] }, "render_settings": { "engine": "EEVEE", "samples": 64, "resolution_x": 1920, "resolution_y": 1080, "use_motion_blur": True }, "world_settings": { "use_hdri": False, "background_color": [0.1, 0.1, 0.1] } }, "motion_graphics": { "lighting": "flat", "camera_settings": { "focal_length": 35, "location": [0, -10, 0], "rotation": [90, 0, 0] }, "render_settings": { "engine": "EEVEE", "samples": 32, "resolution_x": 1920, "resolution_y": 1080, "use_bloom": True }, "world_settings": { "use_hdri": False, "background_color": [1, 1, 1] } } } if preset not in presets: raise ValueError(f"Unknown preset: {preset}") settings = presets[preset] # Apply camera settings cam = create_camera( name=f"{preset}_Camera", **settings["camera_settings"] ) bpy.context.scene.camera = bpy.data.objects.get(cam["name"]) results["camera"] = cam["name"] # Apply render settings render = configure_render_settings(**settings["render_settings"]) results["render"] = render # Setup lighting lighting_preset = settings["lighting"] if lighting_preset == "three_point": # Key light key = create_light( light_type="AREA", name="Key_Light", location=[5, -5, 5], rotation=[45, 0, 45], energy=1000, size=2 ) # Fill light fill = create_light( light_type="AREA", name="Fill_Light", location=[-3, -3, 3], rotation=[60, 0, -30], energy=500, size=3 ) # Rim light rim = create_light( light_type="AREA", name="Rim_Light", location=[0, 5, 3], rotation=[-60, 0, 0], energy=800, size=1 ) results["lights"] = ["Key_Light", "Fill_Light", "Rim_Light"] elif lighting_preset == "sun_sky": sun = create_light( light_type="SUN", name="Sun", rotation=[45, 0, settings["world_settings"].get("sun_rotation", 0)], energy=5 ) results["lights"] = ["Sun"] elif lighting_preset == "studio": # Create studio lighting setup for i in range(3): angle = i * 120 x = math.cos(math.radians(angle)) * 5 y = math.sin(math.radians(angle)) * 5 light = create_light( light_type="AREA", name=f"Studio_Light_{i+1}", location=[x, y, 4], energy=800, size=2 ) results["lights"] = [f"Studio_Light_{i+1}" for i in range(3)] # Setup world world = bpy.context.scene.world if world: world.use_nodes = True nodes = world.node_tree.nodes # Clear existing nodes.clear() # Add background node bg = nodes.new('ShaderNodeBackground') bg.location = (0, 0) # Add output output = nodes.new('ShaderNodeOutputWorld') output.location = (200, 0) # Set color bg_color = settings["world_settings"].get("background_color", [0.05, 0.05, 0.05]) bg.inputs['Color'].default_value = (*bg_color, 1.0) bg.inputs['Strength'].default_value = 1.0 # Connect world.node_tree.links.new(bg.outputs['Background'], output.inputs['Surface']) results["preset"] = preset results["created_at"] = datetime.now().isoformat() return results @thread_safe def create_hdri_environment( hdri_path: Optional[str] = None, strength: float = 1.0, rotation: float = 0.0 ) -> Dict[str, Any]: """ Setup HDRI environment lighting. Args: hdri_path: Path to HDRI image strength: Environment strength rotation: Z rotation in degrees Returns: HDRI setup results """ world = bpy.context.scene.world world.use_nodes = True nodes = world.node_tree.nodes links = world.node_tree.links # Clear existing nodes nodes.clear() # Add nodes coord = nodes.new('ShaderNodeTexCoord') coord.location = (-800, 0) mapping = nodes.new('ShaderNodeMapping') mapping.location = (-600, 0) mapping.inputs['Rotation'].default_value[2] = math.radians(rotation) env_tex = nodes.new('ShaderNodeTexEnvironment') env_tex.location = (-400, 0) if hdri_path and os.path.exists(hdri_path): img = bpy.data.images.load(hdri_path) env_tex.image = img bg = nodes.new('ShaderNodeBackground') bg.location = (-200, 0) bg.inputs['Strength'].default_value = strength output = nodes.new('ShaderNodeOutputWorld') output.location = (0, 0) # Connect nodes links.new(coord.outputs['Generated'], mapping.inputs['Vector']) links.new(mapping.outputs['Vector'], env_tex.inputs['Vector']) links.new(env_tex.outputs['Color'], bg.inputs['Color']) links.new(bg.outputs['Background'], output.inputs['Surface']) return { "hdri_loaded": hdri_path is not None, "strength": strength, "rotation": rotation, "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ“ฆ OBJECT CREATION & PRIMITIVES # ============================================ @thread_safe def create_mesh_object( primitive_type: str = "cube", size: float = 2.0, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None, name: Optional[str] = None, subdivisions: int = 0, segments: int = 32, rings: int = 16, vertices: int = 32, major_segments: int = 48, minor_segments: int = 12, ico_subdivisions: int = 2, x_subdivisions: int = 10, y_subdivisions: int = 10, collection: Optional[str] = None ) -> Dict[str, Any]: """ Create any mesh primitive object with full control. Args: primitive_type: Type of primitive (cube, sphere, cylinder, cone, torus, plane, ico_sphere, monkey, grid) size: Base size of the object location: 3D position [x, y, z] rotation: Rotation in degrees [x, y, z] scale: Scale factors [x, y, z] name: Custom name for the object subdivisions: Subdivision surface levels segments: Segments for UV sphere rings: Rings for UV sphere vertices: Vertices for cylinder/cone major_segments: Major segments for torus minor_segments: Minor segments for torus ico_subdivisions: Subdivisions for ico sphere x_subdivisions: X subdivisions for grid y_subdivisions: Y subdivisions for grid collection: Target collection name Returns: Complete object information including statistics """ # Parameter validation and defaults location = location or [0, 0, 0] rotation = rotation or [0, 0, 0] scale = scale or [1, 1, 1] # Validate primitive type valid_primitives = ["cube", "sphere", "cylinder", "cone", "torus", "plane", "ico_sphere", "monkey", "grid"] if primitive_type not in valid_primitives: raise ValueError(f"Invalid primitive type. Must be one of: {valid_primitives}") # Store initial object count for tracking initial_objects = set(bpy.data.objects.keys()) # Create primitive based on type creation_ops = { "cube": lambda: bpy.ops.mesh.primitive_cube_add( size=size, location=location ), "sphere": lambda: bpy.ops.mesh.primitive_uv_sphere_add( radius=size/2, segments=segments, ring_count=rings, location=location ), "cylinder": lambda: bpy.ops.mesh.primitive_cylinder_add( radius=size/2, depth=size, vertices=vertices, location=location ), "cone": lambda: bpy.ops.mesh.primitive_cone_add( radius1=size/2, radius2=0, depth=size, vertices=vertices, location=location ), "torus": lambda: bpy.ops.mesh.primitive_torus_add( major_radius=size/2, minor_radius=size/4, major_segments=major_segments, minor_segments=minor_segments, location=location ), "plane": lambda: bpy.ops.mesh.primitive_plane_add( size=size, location=location ), "ico_sphere": lambda: bpy.ops.mesh.primitive_ico_sphere_add( radius=size/2, subdivisions=ico_subdivisions, location=location ), "monkey": lambda: bpy.ops.mesh.primitive_monkey_add( size=size, location=location ), "grid": lambda: bpy.ops.mesh.primitive_grid_add( x_subdivisions=x_subdivisions, y_subdivisions=y_subdivisions, size=size, location=location ), } # Execute creation creation_ops[primitive_type]() # Get the newly created object obj = bpy.context.active_object if not obj: new_objects = set(bpy.data.objects.keys()) - initial_objects if new_objects: obj = bpy.data.objects[new_objects.pop()] else: raise RuntimeError("Failed to create object") # Apply custom name if name: obj.name = name if obj.data: obj.data.name = f"{name}_mesh" # Apply transformations obj.rotation_euler = [math.radians(r) for r in rotation] obj.scale = scale # Apply subdivision surface if requested if subdivisions > 0: modifier = obj.modifiers.new(name="Subdivision", type='SUBSURF') modifier.levels = subdivisions modifier.render_levels = subdivisions modifier.uv_smooth = 'PRESERVE_BOUNDARIES' # Move to collection if specified if collection: target_collection = bpy.data.collections.get(collection) if not target_collection: target_collection = bpy.data.collections.new(collection) bpy.context.scene.collection.children.link(target_collection) # Move object to target collection for coll in obj.users_collection: coll.objects.unlink(obj) target_collection.objects.link(obj) # Calculate statistics mesh_stats = { "vertices": len(obj.data.vertices), "edges": len(obj.data.edges), "faces": len(obj.data.polygons), "triangles": sum(len(p.vertices) - 2 for p in obj.data.polygons), } # Calculate bounding box bbox = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box] bbox_min = [min(v[i] for v in bbox) for i in range(3)] bbox_max = [max(v[i] for v in bbox) for i in range(3)] dimensions = [bbox_max[i] - bbox_min[i] for i in range(3)] return { "name": obj.name, "type": primitive_type, "location": list(obj.location), "rotation_degrees": rotation, "scale": list(obj.scale), "statistics": mesh_stats, "bounding_box": { "min": bbox_min, "max": bbox_max, "dimensions": dimensions, "volume": dimensions[0] * dimensions[1] * dimensions[2] }, "subdivisions": subdivisions, "collection": collection or "Scene Collection", "created_at": datetime.now().isoformat() } @thread_safe def create_curve_object( curve_type: str = "bezier", points: Optional[List[List[float]]] = None, name: Optional[str] = None, bevel_depth: float = 0.0, bevel_resolution: int = 4, resolution: int = 12, cyclic: bool = False, dimensions: str = "3D", fill_mode: str = "FULL", twist_mode: str = "MINIMUM", taper_object: Optional[str] = None, bevel_object: Optional[str] = None ) -> Dict[str, Any]: """Create curve objects (bezier, nurbs, path).""" # Default points if not provided if points is None: points = [[0, 0, 0], [2, 0, 1], [2, 2, 0], [0, 2, -1]] # Create curve data curve_data = bpy.data.curves.new( name=name or f"Curve_{curve_type}", type='CURVE' ) # Configure curve properties curve_data.dimensions = dimensions curve_data.bevel_depth = bevel_depth curve_data.bevel_resolution = bevel_resolution curve_data.resolution_u = resolution curve_data.fill_mode = fill_mode curve_data.twist_mode = twist_mode # Create appropriate spline if curve_type == "bezier": spline = curve_data.splines.new('BEZIER') # Add bezier points (giร  ne ha uno di default) spline.bezier_points.add(len(points) - 1) for i, point in enumerate(points): bp = spline.bezier_points[i] bp.co = point # FIX: usa handle_left_type e handle_right_type bp.handle_left_type = 'AUTO' bp.handle_right_type = 'AUTO' bp.radius = 1.0 elif curve_type == "nurbs": spline = curve_data.splines.new('NURBS') spline.points.add(len(points) - 1) for i, point in enumerate(points): spline.points[i].co = (*point, 1.0) spline.points[i].radius = 1.0 spline.order_u = min(4, len(points)) spline.use_endpoint_u = True else: # poly/path spline = curve_data.splines.new('POLY') spline.points.add(len(points) - 1) for i, point in enumerate(points): spline.points[i].co = (*point, 1.0) spline.points[i].radius = 1.0 spline.use_cyclic_u = cyclic # Create object obj = bpy.data.objects.new(name or f"Curve_{curve_type}", curve_data) bpy.context.collection.objects.link(obj) bpy.context.view_layer.objects.active = obj # Update to calculate length obj.data.update_tag() bpy.context.view_layer.update() # Get curve statistics total_length = 0 # Calcola lunghezza solo se il metodo esiste if hasattr(spline, 'calc_length'): try: total_length = spline.calc_length() except: total_length = 0 return { "name": obj.name, "type": f"curve_{curve_type}", "points": len(points), "cyclic": cyclic, "dimensions": dimensions, "bevel_depth": bevel_depth, "bevel_resolution": bevel_resolution, "resolution": resolution, "length": total_length, "fill_mode": fill_mode, "twist_mode": twist_mode, "created_at": datetime.now().isoformat() } @thread_safe def create_text_object( text: str = "Hello Blender", font_size: float = 1.0, font_path: Optional[str] = None, extrude: float = 0.0, bevel_depth: float = 0.0, bevel_resolution: int = 4, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, name: Optional[str] = None, align_x: str = "LEFT", align_y: str = "TOP", character_spacing: float = 1.0, word_spacing: float = 1.0, line_spacing: float = 1.0, shear: float = 0.0, offset_x: float = 0.0, offset_y: float = 0.0 ) -> Dict[str, Any]: """ Create professional 3D text object with full typography control. Args: text: Text content (supports multiline) font_size: Size of the font font_path: Path to custom font file extrude: Extrusion depth bevel_depth: Bevel amount bevel_resolution: Bevel quality location: 3D position rotation: Rotation in degrees name: Object name align_x: Horizontal alignment (LEFT, CENTER, RIGHT, JUSTIFY, FLUSH) align_y: Vertical alignment (TOP, CENTER, BOTTOM) character_spacing: Character spacing multiplier word_spacing: Word spacing multiplier line_spacing: Line spacing multiplier shear: Italic shear amount offset_x: Horizontal offset offset_y: Vertical offset Returns: Complete text object information """ location = location or [0, 0, 0] rotation = rotation or [0, 0, 0] # Create text curve font_curve = bpy.data.curves.new(type="FONT", name=name or "Text") # Set text content font_curve.body = text # Typography settings font_curve.size = font_size font_curve.space_character = character_spacing font_curve.space_word = word_spacing font_curve.space_line = line_spacing font_curve.shear = shear font_curve.offset_x = offset_x font_curve.offset_y = offset_y # 3D settings font_curve.extrude = extrude font_curve.bevel_depth = bevel_depth font_curve.bevel_resolution = bevel_resolution # Alignment font_curve.align_x = align_x font_curve.align_y = align_y # Load custom font if specified if font_path and os.path.exists(font_path): try: font = bpy.data.fonts.load(font_path) font_curve.font = font except Exception as e: logger.warning(f"Failed to load font {font_path}: {e}") # Create object obj = bpy.data.objects.new(name or "Text", font_curve) obj.location = location obj.rotation_euler = [math.radians(r) for r in rotation] bpy.context.collection.objects.link(obj) bpy.context.view_layer.objects.active = obj # Calculate text statistics line_count = text.count('\n') + 1 char_count = len(text.replace('\n', '')) word_count = len(text.split()) return { "name": obj.name, "text": text, "statistics": { "characters": char_count, "words": word_count, "lines": line_count }, "font": { "size": font_size, "custom_font": font_path is not None, "shear": shear }, "3d_properties": { "extrude": extrude, "bevel_depth": bevel_depth, "bevel_resolution": bevel_resolution }, "alignment": { "horizontal": align_x, "vertical": align_y }, "spacing": { "character": character_spacing, "word": word_spacing, "line": line_spacing }, "location": list(obj.location), "rotation_degrees": rotation, "created_at": datetime.now().isoformat() } # ============================================ # ๐ŸŽฏ OBJECT MANIPULATION # ============================================ @thread_safe def transform_object( object_name: str, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, scale: Optional[List[float]] = None, delta: bool = False, space: str = "WORLD", apply_transform: bool = False ) -> Dict[str, Any]: """ Transform an object with advanced options. Args: object_name: Name of the object to transform location: New location [x, y, z] or delta if delta=True rotation: Rotation in degrees [x, y, z] scale: Scale factors [x, y, z] delta: If True, values are added to current transform space: Transform space (WORLD, LOCAL, PARENT) apply_transform: Apply the transformation permanently Returns: Complete transform information """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Store original transform for comparison original_transform = { "location": list(obj.location), "rotation": [math.degrees(r) for r in obj.rotation_euler], "scale": list(obj.scale) } # Apply location if location: if delta: obj.location += mathutils.Vector(location) else: if space == "WORLD": obj.location = location elif space == "LOCAL": obj.location = obj.matrix_world @ mathutils.Vector(location) # Apply rotation if rotation: rot_rad = [math.radians(r) for r in rotation] if delta: obj.rotation_euler[0] += rot_rad[0] obj.rotation_euler[1] += rot_rad[1] obj.rotation_euler[2] += rot_rad[2] else: obj.rotation_euler = rot_rad # Apply scale if scale: if delta: obj.scale[0] *= scale[0] obj.scale[1] *= scale[1] obj.scale[2] *= scale[2] else: obj.scale = scale # Apply transform if requested if apply_transform: bpy.context.view_layer.objects.active = obj if location: bpy.ops.object.location_clear(clear_delta=False) if rotation: bpy.ops.object.rotation_clear(clear_delta=False) if scale: bpy.ops.object.scale_clear(clear_delta=False) # Calculate transform matrix matrix = obj.matrix_world return { "name": obj.name, "original_transform": original_transform, "new_transform": { "location": list(obj.location), "rotation_degrees": [math.degrees(r) for r in obj.rotation_euler], "scale": list(obj.scale) }, "delta_applied": delta, "space": space, "transform_applied": apply_transform, "matrix": [list(row) for row in matrix], "modified_at": datetime.now().isoformat() } @thread_safe def duplicate_object( object_name: str, linked: bool = False, offset: Optional[List[float]] = None, count: int = 1, collection: Optional[str] = None, recursive: bool = True ) -> Dict[str, Any]: """ Duplicate objects with advanced options. Args: object_name: Name of object to duplicate linked: Create linked duplicate (instance) offset: Position offset for each duplicate count: Number of duplicates to create collection: Target collection for duplicates recursive: Also duplicate child objects Returns: Detailed duplication information """ offset = offset or [0, 0, 0] obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") duplicates = [] for i in range(count): # Calculate offset for this duplicate current_offset = mathutils.Vector([ offset[0] * (i + 1), offset[1] * (i + 1), offset[2] * (i + 1) ]) # Duplicate object new_obj = obj.copy() # Duplicate data if not linked if not linked and obj.data: new_obj.data = obj.data.copy() new_obj.data.name = f"{obj.data.name}_copy_{i+1}" new_obj.location = obj.location + current_offset new_obj.name = f"{obj.name}_copy_{i+1}" # Add to collection if collection: target_coll = bpy.data.collections.get(collection) if not target_coll: target_coll = bpy.data.collections.new(collection) bpy.context.scene.collection.children.link(target_coll) target_coll.objects.link(new_obj) else: bpy.context.collection.objects.link(new_obj) # Duplicate children if recursive if recursive and obj.children: for child in obj.children: child_dup = duplicate_object( child.name, linked=linked, offset=[0, 0, 0], count=1, collection=collection, recursive=True ) if child_dup['duplicates']: child_obj = bpy.data.objects.get(child_dup['duplicates'][0]['name']) if child_obj: child_obj.parent = new_obj child_obj.location = child.location duplicates.append({ "name": new_obj.name, "linked": linked, "location": list(new_obj.location) }) return { "original": object_name, "duplicates": duplicates, "count": count, "linked": linked, "recursive": recursive, "collection": collection, "created_at": datetime.now().isoformat() } @thread_safe def delete_objects( object_names: List[str], delete_data: bool = True, delete_hierarchy: bool = False, confirm: bool = True ) -> Dict[str, Any]: """ Delete objects with comprehensive options. Args: object_names: List of object names to delete delete_data: Also delete mesh/curve data delete_hierarchy: Delete children as well confirm: Safety confirmation (set False to force delete) Returns: Detailed deletion report """ if not confirm: logger.warning("Deleting objects without confirmation") deleted = [] not_found = [] data_deleted = [] children_deleted = [] for name in object_names: obj = bpy.data.objects.get(name) if not obj: not_found.append(name) continue # Collect children if deleting hierarchy if delete_hierarchy: def get_all_children(obj): children = [] for child in obj.children: children.append(child.name) children.extend(get_all_children(child)) return children child_names = get_all_children(obj) children_deleted.extend(child_names) # Delete children first for child_name in child_names: child = bpy.data.objects.get(child_name) if child: bpy.data.objects.remove(child) # Store data reference before deletion obj_data = obj.data obj_data_name = obj_data.name if obj_data else None # Delete the object bpy.data.objects.remove(obj) deleted.append(name) # Delete orphaned data if requested if delete_data and obj_data: if obj_data.users == 0: if hasattr(bpy.data, 'meshes') and obj_data in bpy.data.meshes.values(): bpy.data.meshes.remove(obj_data) data_deleted.append(('mesh', obj_data_name)) elif hasattr(bpy.data, 'curves') and obj_data in bpy.data.curves.values(): bpy.data.curves.remove(obj_data) data_deleted.append(('curve', obj_data_name)) elif hasattr(bpy.data, 'cameras') and obj_data in bpy.data.cameras.values(): bpy.data.cameras.remove(obj_data) data_deleted.append(('camera', obj_data_name)) elif hasattr(bpy.data, 'lights') and obj_data in bpy.data.lights.values(): bpy.data.lights.remove(obj_data) data_deleted.append(('light', obj_data_name)) return { "deleted": deleted, "not_found": not_found, "data_deleted": data_deleted, "children_deleted": children_deleted, "total_deleted": len(deleted) + len(children_deleted), "total_data_deleted": len(data_deleted), "deleted_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ”ง MODIFIERS SYSTEM # ============================================ @thread_safe def add_modifier( object_name: str, modifier_type: str, settings: Optional[Dict[str, Any]] = None, name: Optional[str] = None, show_viewport: bool = True, show_render: bool = True ) -> Dict[str, Any]: """ Add any modifier to an object with full configuration. Args: object_name: Target object modifier_type: Type of modifier (SUBSURF, ARRAY, MIRROR, etc.) settings: Modifier-specific settings name: Custom name for modifier show_viewport: Show in viewport show_render: Show in render Returns: Complete modifier information """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Validate modifier type valid_modifiers = [ 'ARRAY', 'BEVEL', 'BOOLEAN', 'BUILD', 'DECIMATE', 'EDGE_SPLIT', 'MASK', 'MIRROR', 'MULTIRES', 'REMESH', 'SCREW', 'SKIN', 'SOLIDIFY', 'SUBSURF', 'TRIANGULATE', 'WELD', 'WIREFRAME', 'ARMATURE', 'CAST', 'CURVE', 'DISPLACE', 'HOOK', 'LAPLACIANDEFORM', 'LAPLACIANSMOOTH', 'LATTICE', 'MESH_DEFORM', 'SHRINKWRAP', 'SIMPLE_DEFORM', 'SMOOTH', 'CORRECTIVE_SMOOTH', 'SURFACE_DEFORM', 'WARP', 'WAVE', 'CLOTH', 'COLLISION', 'DYNAMIC_PAINT', 'EXPLODE', 'FLUID', 'OCEAN', 'PARTICLE_INSTANCE', 'PARTICLE_SYSTEM', 'SOFT_BODY', 'SURFACE', 'MESH_CACHE', 'MESH_SEQUENCE_CACHE', 'NODES', 'NORMAL_EDIT', 'UV_PROJECT', 'UV_WARP', 'VERTEX_WEIGHT_EDIT', 'VERTEX_WEIGHT_MIX', 'VERTEX_WEIGHT_PROXIMITY' ] if modifier_type not in valid_modifiers: raise ValueError(f"Invalid modifier type. Must be one of: {valid_modifiers}") # Add modifier modifier = obj.modifiers.new( name=name or modifier_type, type=modifier_type ) # Configure visibility modifier.show_viewport = show_viewport modifier.show_render = show_render # Apply settings based on modifier type settings = settings or {} # Type-specific configurations if modifier_type == "SUBSURF": modifier.levels = settings.get('levels', 2) modifier.render_levels = settings.get('render_levels', 2) modifier.subdivision_type = settings.get('subdivision_type', 'CATMULL_CLARK') modifier.use_creases = settings.get('use_creases', True) modifier.quality = settings.get('quality', 3) elif modifier_type == "ARRAY": modifier.count = settings.get('count', 3) modifier.use_constant_offset = settings.get('use_constant_offset', False) modifier.constant_offset_displace = settings.get('constant_offset', [0, 0, 0]) modifier.use_relative_offset = settings.get('use_relative_offset', True) modifier.relative_offset_displace[0] = settings.get('offset_x', 1.1) modifier.relative_offset_displace[1] = settings.get('offset_y', 0) modifier.relative_offset_displace[2] = settings.get('offset_z', 0) modifier.use_object_offset = settings.get('use_object_offset', False) if settings.get('offset_object'): offset_obj = bpy.data.objects.get(settings['offset_object']) if offset_obj: modifier.offset_object = offset_obj elif modifier_type == "MIRROR": modifier.use_axis[0] = settings.get('x', True) modifier.use_axis[1] = settings.get('y', False) modifier.use_axis[2] = settings.get('z', False) modifier.use_bisect_axis[0] = settings.get('bisect_x', False) modifier.use_bisect_axis[1] = settings.get('bisect_y', False) modifier.use_bisect_axis[2] = settings.get('bisect_z', False) modifier.use_clip = settings.get('use_clip', True) modifier.merge_threshold = settings.get('merge_threshold', 0.001) if settings.get('mirror_object'): mirror_obj = bpy.data.objects.get(settings['mirror_object']) if mirror_obj: modifier.mirror_object = mirror_obj elif modifier_type == "SOLIDIFY": modifier.thickness = settings.get('thickness', 0.1) modifier.offset = settings.get('offset', -1.0) modifier.use_even_offset = settings.get('use_even_offset', True) modifier.use_quality_normals = settings.get('use_quality_normals', True) modifier.use_rim = settings.get('use_rim', True) modifier.use_rim_only = settings.get('use_rim_only', False) elif modifier_type == "BEVEL": modifier.width = settings.get('width', 0.1) modifier.segments = settings.get('segments', 3) modifier.limit_method = settings.get('limit_method', 'ANGLE') if modifier.limit_method == 'ANGLE': modifier.angle_limit = math.radians(settings.get('angle', 30)) modifier.offset_type = settings.get('offset_type', 'OFFSET') modifier.profile = settings.get('profile', 0.5) modifier.use_clamp_overlap = settings.get('use_clamp_overlap', True) elif modifier_type == "BOOLEAN": modifier.operation = settings.get('operation', 'DIFFERENCE') modifier.solver = settings.get('solver', 'FAST') if settings.get('object'): bool_obj = bpy.data.objects.get(settings['object']) if bool_obj: modifier.object = bool_obj modifier.use_self = settings.get('use_self', False) modifier.use_hole_tolerant = settings.get('use_hole_tolerant', False) # Apply any remaining generic settings for key, value in settings.items(): if hasattr(modifier, key): try: setattr(modifier, key, value) except Exception as e: logger.warning(f"Could not set {key} on modifier: {e}") return { "object": object_name, "modifier": modifier.name, "type": modifier_type, "settings_applied": settings, "show_viewport": show_viewport, "show_render": show_render, "created_at": datetime.now().isoformat() } @thread_safe def apply_modifier( object_name: str, modifier_name: str, apply_as: str = "DATA", keep_original: bool = False ) -> Dict[str, Any]: """ Apply a modifier with advanced options. Args: object_name: Object name modifier_name: Modifier to apply apply_as: How to apply (DATA, SHAPE) keep_original: Keep a copy of the original object Returns: Detailed application result """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") modifier = obj.modifiers.get(modifier_name) if not modifier: raise ValueError(f"Modifier '{modifier_name}' not found on object '{object_name}'") # Statistics before applying stats_before = { "vertices": len(obj.data.vertices) if hasattr(obj.data, 'vertices') else 0, "edges": len(obj.data.edges) if hasattr(obj.data, 'edges') else 0, "faces": len(obj.data.polygons) if hasattr(obj.data, 'polygons') else 0 } # Keep original if requested original_name = None if keep_original: original = duplicate_object(object_name, linked=False, offset=[0, 0, 0]) original_name = original['duplicates'][0]['name'] original_obj = bpy.data.objects.get(original_name) if original_obj: original_obj.name = f"{object_name}_original" original_obj.hide_set(True) original_name = original_obj.name # Select and make active bpy.context.view_layer.objects.active = obj obj.select_set(True) # Apply modifier try: if apply_as == "SHAPE": bpy.ops.object.modifier_apply_as_shapekey(modifier=modifier_name) else: bpy.ops.object.modifier_apply(modifier=modifier_name) except Exception as e: if keep_original and original_name: # Remove the backup if apply failed delete_objects([original_name], delete_data=True) raise RuntimeError(f"Failed to apply modifier: {e}") # Statistics after applying stats_after = { "vertices": len(obj.data.vertices) if hasattr(obj.data, 'vertices') else 0, "edges": len(obj.data.edges) if hasattr(obj.data, 'edges') else 0, "faces": len(obj.data.polygons) if hasattr(obj.data, 'polygons') else 0 } return { "object": object_name, "applied_modifier": modifier_name, "apply_method": apply_as, "statistics": { "before": stats_before, "after": stats_after, "difference": { "vertices": stats_after["vertices"] - stats_before["vertices"], "edges": stats_after["edges"] - stats_before["edges"], "faces": stats_after["faces"] - stats_before["faces"] } }, "original_backup": original_name, "applied_at": datetime.now().isoformat() } # ============================================ # ๐ŸŽจ MATERIALS & SHADING # ============================================ @thread_safe def create_material( name: str, material_type: str = "principled", base_color: Optional[List[float]] = None, metallic: float = 0.0, roughness: float = 0.5, ior: float = 1.45, transmission: float = 0.0, emission_color: Optional[List[float]] = None, emission_strength: float = 0.0, alpha: float = 1.0, use_backface_culling: bool = False, blend_mode: str = "OPAQUE", use_nodes: bool = True ) -> Dict[str, Any]: """ Create production-ready materials with full PBR support. Args: name: Material name material_type: Shader type (principled, emission, glass, glossy, transparent) base_color: Base color RGBA metallic: Metallic value (0-1) roughness: Roughness value (0-1) ior: Index of refraction transmission: Transmission for glass (0-1) emission_color: Emission color RGBA emission_strength: Emission strength alpha: Alpha transparency use_backface_culling: Enable backface culling blend_mode: Blend mode (OPAQUE, CLIP, BLEND) use_nodes: Use node-based shading Returns: Complete material information """ base_color = base_color or [0.8, 0.8, 0.8, 1.0] emission_color = emission_color or [0, 0, 0, 1] # Create material mat = bpy.data.materials.new(name=name) mat.use_nodes = use_nodes mat.use_backface_culling = use_backface_culling mat.blend_method = blend_mode if use_nodes: nodes = mat.node_tree.nodes links = mat.node_tree.links # Clear existing nodes nodes.clear() # Add output node output = nodes.new(type='ShaderNodeOutputMaterial') output.location = (400, 0) # Create shader based on type if material_type == "principled": bsdf = nodes.new(type='ShaderNodeBsdfPrincipled') bsdf.location = (0, 0) # Set all principled BSDF parameters with compatibility checks # Base properties (always available) bsdf.inputs['Base Color'].default_value = base_color bsdf.inputs['Metallic'].default_value = metallic bsdf.inputs['Roughness'].default_value = roughness # IOR - check different naming conventions if 'IOR' in bsdf.inputs: bsdf.inputs['IOR'].default_value = ior elif 'Index of Refraction' in bsdf.inputs: bsdf.inputs['Index of Refraction'].default_value = ior # Transmission - Blender 4.0+ uses "Transmission Weight" if transmission > 0: if 'Transmission' in bsdf.inputs: bsdf.inputs['Transmission'].default_value = transmission elif 'Transmission Weight' in bsdf.inputs: bsdf.inputs['Transmission Weight'].default_value = transmission # Alpha if 'Alpha' in bsdf.inputs: bsdf.inputs['Alpha'].default_value = alpha # Emission - check for both old and new naming if emission_strength > 0: # Blender 4.0+ separates emission into color and strength if 'Emission Color' in bsdf.inputs: bsdf.inputs['Emission Color'].default_value = emission_color if 'Emission Strength' in bsdf.inputs: bsdf.inputs['Emission Strength'].default_value = emission_strength elif 'Emission' in bsdf.inputs: # Older Blender versions bsdf.inputs['Emission'].default_value = emission_color[:3] + [1.0] if 'Emission Strength' in bsdf.inputs: bsdf.inputs['Emission Strength'].default_value = emission_strength links.new(bsdf.outputs['BSDF'], output.inputs['Surface']) elif material_type == "emission": emission = nodes.new(type='ShaderNodeEmission') emission.location = (0, 0) emission.inputs['Color'].default_value = emission_color emission.inputs['Strength'].default_value = max(emission_strength, 1.0) links.new(emission.outputs['Emission'], output.inputs['Surface']) elif material_type == "glass": glass = nodes.new(type='ShaderNodeBsdfGlass') glass.location = (0, 0) glass.inputs['Color'].default_value = base_color glass.inputs['Roughness'].default_value = roughness # IOR compatibility if 'IOR' in glass.inputs: glass.inputs['IOR'].default_value = ior elif 'Index of Refraction' in glass.inputs: glass.inputs['Index of Refraction'].default_value = ior links.new(glass.outputs['BSDF'], output.inputs['Surface']) elif material_type == "glossy": glossy = nodes.new(type='ShaderNodeBsdfGlossy') glossy.location = (0, 0) glossy.inputs['Color'].default_value = base_color glossy.inputs['Roughness'].default_value = roughness links.new(glossy.outputs['BSDF'], output.inputs['Surface']) elif material_type == "transparent": transparent = nodes.new(type='ShaderNodeBsdfTransparent') transparent.location = (0, 0) transparent.inputs['Color'].default_value = base_color links.new(transparent.outputs['BSDF'], output.inputs['Surface']) # Generate unique ID for material material_id = hashlib.md5(name.encode()).hexdigest()[:8] return { "name": mat.name, "id": material_id, "type": material_type, "properties": { "base_color": base_color, "metallic": metallic, "roughness": roughness, "ior": ior, "transmission": transmission, "alpha": alpha, "emission_color": emission_color, "emission_strength": emission_strength }, "settings": { "use_nodes": use_nodes, "use_backface_culling": use_backface_culling, "blend_mode": blend_mode }, "node_tree": mat.node_tree.name if use_nodes else None, "created_at": datetime.now().isoformat() } @thread_safe def assign_material( object_name: str, material_name: str, slot_index: int = -1, assign_to: str = "OBJECT" ) -> Dict[str, Any]: """ Assign material with advanced options. Args: object_name: Target object material_name: Material to assign slot_index: Material slot index (-1 for new slot) assign_to: Assignment target (OBJECT, DATA) Returns: Complete assignment information """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") mat = bpy.data.materials.get(material_name) if not mat: raise ValueError(f"Material '{material_name}' not found") # Check if object can have materials if not hasattr(obj.data, 'materials'): raise ValueError(f"Object '{object_name}' cannot have materials") # Store previous material for reference previous_material = None if slot_index == -1: # Add new slot obj.data.materials.append(mat) slot_index = len(obj.material_slots) - 1 else: # Replace existing slot if slot_index < len(obj.material_slots): previous_material = obj.material_slots[slot_index].material if assign_to == "OBJECT": obj.material_slots[slot_index].material = mat else: obj.data.materials[slot_index] = mat else: # Extend slots if necessary while len(obj.data.materials) <= slot_index: obj.data.materials.append(None) obj.data.materials[slot_index] = mat return { "object": object_name, "material": material_name, "slot_index": slot_index, "total_slots": len(obj.material_slots), "previous_material": previous_material.name if previous_material else None, "assign_to": assign_to, "assigned_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ’ก LIGHTING SYSTEM # ============================================ @thread_safe def create_light( light_type: str = "POINT", name: Optional[str] = None, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, energy: float = 1000.0, color: Optional[List[float]] = None, size: float = 0.25, spot_size: float = 45.0, spot_blend: float = 0.15, shadow_soft_size: float = 0.25, use_shadow: bool = True, use_contact_shadow: bool = False ) -> Dict[str, Any]: """ Create professional lighting with full control. Args: light_type: Type of light (POINT, SUN, SPOT, AREA) name: Light name location: 3D position rotation: Rotation in degrees energy: Light power/energy color: Light color RGB size: Light size (for area/point lights) spot_size: Spot cone angle in degrees spot_blend: Spot edge softness shadow_soft_size: Shadow softness use_shadow: Enable shadows use_contact_shadow: Enable contact shadows Returns: Complete light information """ location = location or [0, 0, 5] rotation = rotation or [0, 0, 0] color = color or [1, 1, 1] # Validate light type valid_types = ["POINT", "SUN", "SPOT", "AREA"] if light_type not in valid_types: raise ValueError(f"Invalid light type. Must be one of: {valid_types}") # Create light data light_data = bpy.data.lights.new( name=name or f"Light_{light_type}", type=light_type ) # Configure light properties light_data.energy = energy light_data.color = color light_data.use_shadow = use_shadow light_data.use_contact_shadow = use_contact_shadow # Type-specific settings if light_type == "POINT": light_data.shadow_soft_size = shadow_soft_size elif light_type == "SUN": light_data.angle = math.radians(size) elif light_type == "SPOT": light_data.spot_size = math.radians(spot_size) light_data.spot_blend = spot_blend light_data.shadow_soft_size = shadow_soft_size elif light_type == "AREA": light_data.shape = 'SQUARE' light_data.size = size # Create light object light_obj = bpy.data.objects.new( name=name or f"Light_{light_type}", object_data=light_data ) light_obj.location = location light_obj.rotation_euler = [math.radians(r) for r in rotation] bpy.context.collection.objects.link(light_obj) # Calculate illumination power luminous_power = energy * (color[0] + color[1] + color[2]) / 3 return { "name": light_obj.name, "type": light_type, "location": list(light_obj.location), "rotation_degrees": rotation, "properties": { "energy": energy, "color": color, "luminous_power": luminous_power, "size": size, "spot_size": spot_size if light_type == "SPOT" else None, "spot_blend": spot_blend if light_type == "SPOT" else None }, "shadows": { "enabled": use_shadow, "contact_shadows": use_contact_shadow, "softness": shadow_soft_size }, "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ“ท CAMERA SYSTEM # ============================================ @thread_safe def create_camera( name: Optional[str] = None, location: Optional[List[float]] = None, rotation: Optional[List[float]] = None, camera_type: str = "PERSP", focal_length: float = 50.0, sensor_width: float = 36.0, sensor_height: float = 24.0, sensor_fit: str = "AUTO", depth_of_field: bool = False, focus_object: Optional[str] = None, focus_distance: float = 10.0, f_stop: float = 2.8, blade_count: int = 0, blade_rotation: float = 0.0, orthographic_scale: float = 6.0, clip_start: float = 0.1, clip_end: float = 1000.0, passepartout_alpha: float = 0.5 ) -> Dict[str, Any]: """ Create professional camera with complete settings. Args: name: Camera name location: 3D position rotation: Rotation in degrees camera_type: Camera type (PERSP, ORTHO, PANO) focal_length: Lens focal length in mm sensor_width: Sensor width in mm sensor_height: Sensor height in mm sensor_fit: Sensor fit mode (AUTO, HORIZONTAL, VERTICAL) depth_of_field: Enable DoF focus_object: Object name to focus on focus_distance: Manual focus distance f_stop: Aperture f-stop blade_count: Aperture blade count (0 for circle) blade_rotation: Aperture blade rotation orthographic_scale: Scale for orthographic camera clip_start: Near clipping distance clip_end: Far clipping distance passepartout_alpha: Viewport passepartout opacity Returns: Complete camera information """ location = location or [7, -7, 5] rotation = rotation or [60, 0, 45] # Create camera data cam_data = bpy.data.cameras.new(name=name or "Camera") # Basic settings cam_data.type = camera_type cam_data.lens = focal_length cam_data.sensor_width = sensor_width cam_data.sensor_height = sensor_height cam_data.sensor_fit = sensor_fit cam_data.clip_start = clip_start cam_data.clip_end = clip_end cam_data.passepartout_alpha = passepartout_alpha # Orthographic settings if camera_type == "ORTHO": cam_data.ortho_scale = orthographic_scale # Depth of field settings if depth_of_field: cam_data.dof.use_dof = True cam_data.dof.aperture_fstop = f_stop cam_data.dof.aperture_blades = blade_count cam_data.dof.aperture_rotation = math.radians(blade_rotation) if focus_object: focus_obj = bpy.data.objects.get(focus_object) if focus_obj: cam_data.dof.focus_object = focus_obj else: cam_data.dof.focus_distance = focus_distance # Create camera object cam_obj = bpy.data.objects.new(name or "Camera", cam_data) cam_obj.location = location cam_obj.rotation_euler = [math.radians(r) for r in rotation] bpy.context.collection.objects.link(cam_obj) # Calculate field of view fov_horizontal = 2 * math.atan(sensor_width / (2 * focal_length)) fov_vertical = 2 * math.atan(sensor_height / (2 * focal_length)) return { "name": cam_obj.name, "type": camera_type, "location": list(cam_obj.location), "rotation_degrees": rotation, "lens": { "focal_length": focal_length, "fov_horizontal": math.degrees(fov_horizontal), "fov_vertical": math.degrees(fov_vertical) }, "sensor": { "width": sensor_width, "height": sensor_height, "fit": sensor_fit, "aspect_ratio": sensor_width / sensor_height }, "depth_of_field": { "enabled": depth_of_field, "f_stop": f_stop if depth_of_field else None, "focus_object": focus_object, "focus_distance": focus_distance if not focus_object else None, "blades": blade_count if depth_of_field else None }, "clipping": { "start": clip_start, "end": clip_end }, "created_at": datetime.now().isoformat() } # ============================================ # ๐ŸŽฌ ANIMATION SYSTEM # ============================================ @thread_safe def create_keyframe( object_name: str, frame: int, property_path: str = "location", values: Optional[List[float]] = None, interpolation: str = "BEZIER", easing: str = "AUTO", handle_type: str = "AUTO" ) -> Dict[str, Any]: """ Create advanced animation keyframe. Args: object_name: Object to animate frame: Frame number property_path: Property to animate values: Property values interpolation: Interpolation type (BEZIER, LINEAR, CONSTANT, BOUNCE, etc.) easing: Easing type (AUTO, EASE_IN, EASE_OUT, EASE_IN_OUT) handle_type: Handle type for bezier (AUTO, VECTOR, ALIGNED, FREE) Returns: Complete keyframe information """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Set frame bpy.context.scene.frame_set(frame) # Apply values if provided if values is not None: if property_path == "location": obj.location = values elif property_path == "rotation_euler": obj.rotation_euler = [math.radians(v) for v in values] elif property_path == "scale": obj.scale = values else: # Handle custom properties if "." in property_path: # Nested property parts = property_path.split(".") target = obj for part in parts[:-1]: target = getattr(target, part) setattr(target, parts[-1], values) else: setattr(obj, property_path, values) # Insert keyframe obj.keyframe_insert(data_path=property_path, frame=frame) # Configure keyframe properties if obj.animation_data and obj.animation_data.action: for fcurve in obj.animation_data.action.fcurves: if fcurve.data_path == property_path: for keyframe in fcurve.keyframe_points: if keyframe.co[0] == frame: keyframe.interpolation = interpolation keyframe.easing = easing keyframe.handle_left_type = handle_type keyframe.handle_right_type = handle_type # Get actual values actual_values = None if property_path == "location": actual_values = list(obj.location) elif property_path == "rotation_euler": actual_values = [math.degrees(r) for r in obj.rotation_euler] elif property_path == "scale": actual_values = list(obj.scale) else: try: actual_values = getattr(obj, property_path) if hasattr(actual_values, '__iter__'): actual_values = list(actual_values) except: actual_values = values return { "object": object_name, "frame": frame, "property": property_path, "values": actual_values, "interpolation": interpolation, "easing": easing, "handle_type": handle_type, "created_at": datetime.now().isoformat() } # ============================================ # ๐Ÿงญ SPATIAL AWARENESS FOR LLM # ============================================ def get_object_position(object_name: str) -> Dict[str, Any]: """ Get exact position and bounds of an object. Returns: Dict with location, center, size, bounding_box """ obj = bpy.data.objects.get(object_name) if not obj: raise ValueError(f"Object '{object_name}' not found") # Calcola bounding box in world space bbox_world = [obj.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box] center = sum(bbox_world, mathutils.Vector()) / 8 min_co = [min(v[i] for v in bbox_world) for i in range(3)] max_co = [max(v[i] for v in bbox_world) for i in range(3)] size = [max_co[i] - min_co[i] for i in range(3)] return { "object": object_name, "location": list(obj.location), # Origin point "center": list(center), # True center "size": size, # [width_x, depth_y, height_z] "radius": max(size) / 2, "bounding_box": { "min": min_co, "max": max_co }, "front": [center[0], max_co[1], center[2]], # Front center point "back": [center[0], min_co[1], center[2]], # Back center point "top": [center[0], center[1], max_co[2]], # Top center point "bottom": [center[0], center[1], min_co[2]], # Bottom center point "left": [min_co[0], center[1], center[2]], # Left center point "right": [max_co[0], center[1], center[2]] # Right center point } def calculate_position_relative_to( reference_object: str, direction: str = "front", # front, back, left, right, top, bottom, center distance: float = 1.0, offset: Optional[List[float]] = None # Additional [x, y, z] offset ) -> List[float]: """ Calculate a position relative to another object. Args: reference_object: Name of reference object direction: Direction relative to object (front, back, left, right, top, bottom, center) distance: Distance from object surface offset: Additional offset [x, y, z] Returns: Position coordinates [x, y, z] """ obj_info = get_object_position(reference_object) center = obj_info["center"] size = obj_info["size"] offset = offset or [0, 0, 0] # Calculate base position based on direction positions = { "front": [center[0], center[1] + size[1]/2 + distance, center[2]], "back": [center[0], center[1] - size[1]/2 - distance, center[2]], "left": [center[0] - size[0]/2 - distance, center[1], center[2]], "right": [center[0] + size[0]/2 + distance, center[1], center[2]], "top": [center[0], center[1], center[2] + size[2]/2 + distance], "bottom": [center[0], center[1], center[2] - size[2]/2 - distance], "center": center.copy() } if direction not in positions: raise ValueError(f"Invalid direction. Use: {list(positions.keys())}") position = positions[direction] # Apply additional offset return [position[0] + offset[0], position[1] + offset[1], position[2] + offset[2]] def get_all_objects_positions() -> Dict[str, Any]: """ Get positions of all objects in the scene. Returns: Dictionary with all objects and their positions """ objects_map = {} for obj in bpy.data.objects: objects_map[obj.name] = { "type": obj.type, "location": list(obj.location), "size": [ abs(max(v[0] for v in obj.bound_box) - min(v[0] for v in obj.bound_box)), abs(max(v[1] for v in obj.bound_box) - min(v[1] for v in obj.bound_box)), abs(max(v[2] for v in obj.bound_box) - min(v[2] for v in obj.bound_box)) ] if obj.bound_box else [0, 0, 0] } # Calcola anche relazioni spaziali semplici if len(objects_map) > 1: relationships = [] obj_names = list(objects_map.keys()) for i, obj_a in enumerate(obj_names[:5]): # Limita a 5 oggetti per semplicitร  for obj_b in obj_names[i+1:i+3]: # Max 2 relazioni per oggetto loc_a = mathutils.Vector(objects_map[obj_a]["location"]) loc_b = mathutils.Vector(objects_map[obj_b]["location"]) diff = loc_b - loc_a distance = diff.length # Determina relazione principale if abs(diff.x) > abs(diff.y) and abs(diff.x) > abs(diff.z): relation = f"{obj_b} is {'right of' if diff.x > 0 else 'left of'} {obj_a}" elif abs(diff.y) > abs(diff.z): relation = f"{obj_b} is {'in front of' if diff.y > 0 else 'behind'} {obj_a}" else: relation = f"{obj_b} is {'above' if diff.z > 0 else 'below'} {obj_a}" relationships.append(f"{relation} (distance: {distance:.2f})") else: relationships = [] return { "total_objects": len(objects_map), "objects": objects_map, "relationships": relationships[:10], # Max 10 relazioni "coordinate_system": { "x": "right/left (-x = left, +x = right)", "y": "forward/back (-y = back, +y = forward/front)", "z": "up/down (-z = down, +z = up)" } } def find_empty_space( size_needed: float = 1.0, preferred_height: float = 0.0, avoid_center: bool = False ) -> List[float]: """ Find empty space in the scene for a new object. Args: size_needed: Radius of space needed preferred_height: Preferred Z coordinate avoid_center: Avoid the center of the scene Returns: Position [x, y, z] of empty space """ occupied_spaces = [] for obj in bpy.data.objects: if obj.type == 'MESH': occupied_spaces.append({ "center": list(obj.location), "radius": max(obj.dimensions) / 2 if any(obj.dimensions) else 0.5 }) # Cerca spazio libero in una griglia for x in [-3, -1.5, 0, 1.5, 3]: for y in [-3, -1.5, 0, 1.5, 3]: if avoid_center and x == 0 and y == 0: continue position = [x, y, preferred_height] # Controlla se รจ libero is_free = True for occupied in occupied_spaces: distance = mathutils.Vector(position).length if distance < (size_needed + occupied["radius"]): is_free = False break if is_free: return position # Se non trova spazio, ritorna posizione offset return [len(occupied_spaces) * 2, 0, preferred_height] def align_objects_in_grid( object_names: List[str], spacing: float = 2.0, columns: int = 3, start_position: Optional[List[float]] = None ) -> Dict[str, Any]: """ Align objects in a grid pattern. Args: object_names: List of object names to align spacing: Distance between objects columns: Number of columns in grid start_position: Starting position for grid Returns: New positions for all objects """ start = start_position or [0, 0, 0] positions = {} for i, obj_name in enumerate(object_names): obj = bpy.data.objects.get(obj_name) if obj: row = i // columns col = i % columns new_position = [ start[0] + col * spacing, start[1] - row * spacing, start[2] ] obj.location = new_position positions[obj_name] = new_position return { "aligned_objects": positions, "grid_size": [columns, (len(object_names) - 1) // columns + 1], "spacing": spacing } # ============================================ # ๐Ÿ–ผ๏ธ RENDERING ENGINE # ============================================ @thread_safe def configure_render_settings( engine: str = "CYCLES", device: str = "GPU", samples: int = 128, resolution_x: int = 1920, resolution_y: int = 1080, resolution_percentage: int = 100, file_format: str = "PNG", color_mode: str = "RGBA", color_depth: str = "16", compression: int = 15, transparent: bool = False, use_denoising: bool = True, use_motion_blur: bool = False, motion_blur_shutter: float = 0.5, use_bloom: bool = False, bloom_intensity: float = 0.5, bloom_threshold: float = 0.8, exposure: float = 1.0, gamma: float = 1.0, use_compositing: bool = True, use_sequencer: bool = False ) -> Dict[str, Any]: """ Configure professional render settings. Args: engine: Render engine (CYCLES, EEVEE, WORKBENCH) device: Compute device (CPU, GPU) samples: Number of samples resolution_x: Horizontal resolution resolution_y: Vertical resolution resolution_percentage: Resolution scale percentage file_format: Output format (PNG, JPEG, EXR, TIFF, etc.) color_mode: Color mode (RGB, RGBA, BW) color_depth: Bit depth (8, 16, 32) compression: Compression level (0-100) transparent: Transparent background use_denoising: Enable denoising use_motion_blur: Enable motion blur motion_blur_shutter: Motion blur shutter time use_bloom: Enable bloom effect (EEVEE) bloom_intensity: Bloom intensity bloom_threshold: Bloom threshold exposure: Exposure adjustment gamma: Gamma correction use_compositing: Use compositor use_sequencer: Use sequencer Returns: Complete render configuration """ scene = bpy.context.scene render = scene.render # Basic settings render.engine = engine render.resolution_x = resolution_x render.resolution_y = resolution_y render.resolution_percentage = resolution_percentage render.use_compositing = use_compositing render.use_sequencer = use_sequencer # File output settings render.image_settings.file_format = file_format render.image_settings.color_mode = color_mode render.image_settings.color_depth = color_depth if file_format in ['PNG', 'TIFF']: render.image_settings.compression = compression # Transparency render.film_transparent = transparent # Color management scene.view_settings.exposure = exposure scene.view_settings.gamma = gamma # Engine-specific settings if engine == "CYCLES": cycles = scene.cycles # Device settings cycles.device = device if device == "GPU": # Enable GPU compute prefs = bpy.context.preferences.addons.get('cycles') if prefs: compute_device_type = prefs.preferences.compute_device_type prefs.preferences.compute_device_type = 'CUDA' # or 'OPTIX', 'METAL', 'HIP' # Sampling cycles.samples = samples cycles.use_adaptive_sampling = True cycles.adaptive_threshold = 0.01 # Denoising cycles.use_denoising = use_denoising if use_denoising: cycles.denoiser = 'OPENIMAGEDENOISE' cycles.denoising_input_passes = 'RGB_ALBEDO_NORMAL' # Motion blur render.use_motion_blur = use_motion_blur if use_motion_blur: cycles.motion_blur_position = 'CENTER' render.motion_blur_shutter = motion_blur_shutter elif engine == "EEVEE": eevee = scene.eevee # Sampling eevee.taa_render_samples = samples eevee.taa_samples = min(16, samples) # Effects eevee.use_bloom = use_bloom if use_bloom: eevee.bloom_intensity = bloom_intensity eevee.bloom_threshold = bloom_threshold # Motion blur eevee.use_motion_blur = use_motion_blur if use_motion_blur: eevee.motion_blur_shutter = motion_blur_shutter eevee.motion_blur_samples = 8 return { "engine": engine, "device": device if engine == "CYCLES" else "CPU", "resolution": f"{resolution_x}x{resolution_y}", "resolution_percentage": resolution_percentage, "samples": samples, "file_format": file_format, "color": { "mode": color_mode, "depth": color_depth, "exposure": exposure, "gamma": gamma }, "effects": { "transparent": transparent, "denoising": use_denoising and engine == "CYCLES", "motion_blur": use_motion_blur, "bloom": use_bloom and engine == "EEVEE" }, "performance": { "use_compositing": use_compositing, "use_sequencer": use_sequencer }, "configured_at": datetime.now().isoformat() } @thread_safe def render_image( output_path: Optional[str] = None, animation: bool = False, frame_start: Optional[int] = None, frame_end: Optional[int] = None, frame_step: int = 1, return_base64: bool = False, use_viewport: bool = False ) -> Dict[str, Any]: """ Execute professional rendering. Args: output_path: Output file path animation: Render animation frame_start: Animation start frame frame_end: Animation end frame frame_step: Frame step for animation return_base64: Return image as base64 use_viewport: Use viewport render (faster, lower quality) Returns: Complete render result """ scene = bpy.context.scene render_start_time = time.time() # Set frame range for animation if animation: if frame_start is not None: scene.frame_start = frame_start if frame_end is not None: scene.frame_end = frame_end scene.frame_step = frame_step # Setup output path if output_path: render.render.filepath = output_path # Ensure directory exists os.makedirs(os.path.dirname(output_path), exist_ok=True) else: # Use temporary file tmp = tempfile.NamedTemporaryFile( suffix='.png' if not animation else '.mp4', delete=False ) scene.render.filepath = tmp.name output_path = tmp.name # Execute render try: if use_viewport: # Quick viewport render bpy.ops.render.opengl( animation=animation, write_still=not animation ) else: # Full render bpy.ops.render.render( animation=animation, write_still=not animation ) except Exception as e: raise RuntimeError(f"Render failed: {e}") render_time = time.time() - render_start_time # Get file info file_size = 0 if os.path.exists(output_path): file_size = os.path.getsize(output_path) result = { "output_path": output_path, "animation": animation, "frames_rendered": scene.frame_end - scene.frame_start + 1 if animation else 1, "resolution": f"{scene.render.resolution_x}x{scene.render.resolution_y}", "engine": scene.render.engine, "render_time": render_time, "file_size": file_size, "viewport_render": use_viewport, "rendered_at": datetime.now().isoformat() } # Return base64 if requested (only for still images) if return_base64 and not animation and os.path.exists(output_path): with open(output_path, 'rb') as f: result["image_base64"] = base64.b64encode(f.read()).decode('utf-8') # Cleanup temp file if created if 'tmp' in locals(): os.unlink(output_path) return result # ============================================ # ๐ŸŽฏ SCENE MANAGEMENT (continued) # ============================================ def get_scene_info() -> Dict[str, Any]: """ Get comprehensive scene information. This function doesn't need thread safety as it only reads data. Returns: Complete scene analysis """ scene = bpy.context.scene # Object analysis object_types = {} total_verts = 0 total_edges = 0 total_faces = 0 total_tris = 0 for obj in scene.objects: obj_type = obj.type object_types[obj_type] = object_types.get(obj_type, 0) + 1 if hasattr(obj.data, 'vertices'): total_verts += len(obj.data.vertices) if hasattr(obj.data, 'edges'): total_edges += len(obj.data.edges) if hasattr(obj.data, 'polygons'): total_faces += len(obj.data.polygons) # Calculate triangles for poly in obj.data.polygons: total_tris += len(poly.vertices) - 2 # Memory usage estimation memory_usage = { "objects": len(bpy.data.objects) * 1024, # Rough estimate "meshes": sum(len(m.vertices) * 32 + len(m.polygons) * 64 for m in bpy.data.meshes), "materials": len(bpy.data.materials) * 2048, "textures": sum(img.size[0] * img.size[1] * 4 for img in bpy.data.images if img.size[0] > 0) } memory_usage["total"] = sum(memory_usage.values()) # Collections info collections = {} for coll in bpy.data.collections: collections[coll.name] = { "objects": len(coll.objects), "children": len(coll.children) } return { "scene_name": scene.name, "file_path": bpy.data.filepath or "Unsaved", "objects": { "total": len(scene.objects), "visible": len([o for o in scene.objects if o.visible_get()]), "selected": len([o for o in scene.objects if o.select_get()]), "by_type": object_types, "names": [obj.name for obj in scene.objects] }, "geometry": { "total_vertices": total_verts, "total_edges": total_edges, "total_faces": total_faces, "total_triangles": total_tris }, "materials": { "total": len(bpy.data.materials), "used": len([m for m in bpy.data.materials if m.users > 0]), "names": [mat.name for mat in bpy.data.materials] }, "textures": { "total": len(bpy.data.images), "names": [img.name for img in bpy.data.images], "total_pixels": sum(img.size[0] * img.size[1] for img in bpy.data.images if img.size[0] > 0) }, "animation": { "fps": scene.render.fps, "frame_current": scene.frame_current, "frame_start": scene.frame_start, "frame_end": scene.frame_end, "total_frames": scene.frame_end - scene.frame_start + 1, "actions": len(bpy.data.actions), "armatures": len([o for o in scene.objects if o.type == 'ARMATURE']) }, "render": { "engine": scene.render.engine, "resolution": f"{scene.render.resolution_x}x{scene.render.resolution_y}", "resolution_percentage": scene.render.resolution_percentage, "samples": scene.cycles.samples if scene.render.engine == 'CYCLES' else scene.eevee.taa_render_samples, "file_format": scene.render.image_settings.file_format }, "cameras": [obj.name for obj in scene.objects if obj.type == 'CAMERA'], "lights": [obj.name for obj in scene.objects if obj.type == 'LIGHT'], "active_camera": scene.camera.name if scene.camera else None, "collections": collections, "memory_usage_bytes": memory_usage, "statistics": { "modifiers": sum(len(o.modifiers) for o in scene.objects), "constraints": sum(len(o.constraints) for o in scene.objects), "shape_keys": sum(1 for o in scene.objects if hasattr(o.data, 'shape_keys') and o.data.shape_keys) }, "queried_at": datetime.now().isoformat() } @thread_safe def clear_scene( clear_objects: bool = True, clear_materials: bool = True, clear_textures: bool = True, clear_animations: bool = False, clear_node_groups: bool = False, clear_worlds: bool = False, keep_cameras: bool = True, keep_lights: bool = True ) -> Dict[str, Any]: """ Clear scene with granular control. Args: clear_objects: Remove all objects clear_materials: Remove all materials clear_textures: Remove all textures/images clear_animations: Remove all animations clear_node_groups: Remove node groups clear_worlds: Remove world settings keep_cameras: Preserve cameras keep_lights: Preserve lights Returns: Detailed clear operation report """ removed = { "objects": 0, "materials": 0, "textures": 0, "animations": 0, "node_groups": 0, "worlds": 0, "meshes": 0, "curves": 0 } preserved = { "cameras": [], "lights": [] } if clear_objects: # Preserve cameras and lights if requested for obj in list(bpy.data.objects): should_remove = True if keep_cameras and obj.type == 'CAMERA': preserved["cameras"].append(obj.name) should_remove = False elif keep_lights and obj.type == 'LIGHT': preserved["lights"].append(obj.name) should_remove = False if should_remove: bpy.data.objects.remove(obj) removed["objects"] += 1 # Clean orphaned data if clear_materials: for mat in list(bpy.data.materials): if mat.users == 0 or clear_materials: bpy.data.materials.remove(mat) removed["materials"] += 1 if clear_textures: for img in list(bpy.data.images): if img.users == 0 or clear_textures: bpy.data.images.remove(img) removed["textures"] += 1 if clear_animations: for action in list(bpy.data.actions): bpy.data.actions.remove(action) removed["animations"] += 1 if clear_node_groups: for ng in list(bpy.data.node_groups): bpy.data.node_groups.remove(ng) removed["node_groups"] += 1 if clear_worlds: for world in list(bpy.data.worlds): if world != bpy.context.scene.world: bpy.data.worlds.remove(world) removed["worlds"] += 1 # Clean orphaned mesh data for mesh in list(bpy.data.meshes): if mesh.users == 0: bpy.data.meshes.remove(mesh) removed["meshes"] += 1 # Clean orphaned curve data for curve in list(bpy.data.curves): if curve.users == 0: bpy.data.curves.remove(curve) removed["curves"] += 1 return { "removed": removed, "preserved": preserved, "total_removed": sum(removed.values()), "cleared_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ“ FILE OPERATIONS # ============================================ @thread_safe def save_blend_file( file_path: str, compress: bool = False, copy: bool = False, auto_pack: bool = False, save_preview: bool = True ) -> Dict[str, Any]: """ Save Blender file with options. Args: file_path: Output path for .blend file compress: Compress file copy: Save a copy (don't change current filepath) auto_pack: Pack external data into blend file save_preview: Generate file preview Returns: Complete save information """ # Ensure .blend extension if not file_path.endswith('.blend'): file_path += '.blend' # Ensure directory exists os.makedirs(os.path.dirname(file_path), exist_ok=True) # Pack external data if requested if auto_pack: bpy.ops.file.pack_all() # Store original path if copying original_path = bpy.data.filepath if copy else None try: if copy: bpy.ops.wm.save_as_mainfile( filepath=file_path, compress=compress, copy=True ) else: bpy.ops.wm.save_mainfile( filepath=file_path, compress=compress ) except Exception as e: raise RuntimeError(f"Failed to save file: {e}") # Get file statistics file_stats = os.stat(file_path) return { "file_path": file_path, "file_size": file_stats.st_size, "file_size_mb": file_stats.st_size / (1024 * 1024), "compressed": compress, "copy": copy, "original_path": original_path, "auto_packed": auto_pack, "modified_time": datetime.fromtimestamp(file_stats.st_mtime).isoformat(), "saved_at": datetime.now().isoformat() } @thread_safe def import_file( file_path: str, file_format: Optional[str] = None, use_selection: bool = False, axis_forward: str = '-Z', axis_up: str = 'Y', scale: float = 1.0, apply_transform: bool = True ) -> Dict[str, Any]: """ Import 3D files with comprehensive options. Args: file_path: Path to file file_format: Format (auto-detect if None) use_selection: Import as selection only axis_forward: Forward axis mapping axis_up: Up axis mapping scale: Import scale apply_transform: Apply transformation after import Returns: Complete import information """ if not os.path.exists(file_path): raise ValueError(f"File not found: {file_path}") # Auto-detect format if file_format is None: ext = os.path.splitext(file_path)[1].lower() file_format = ext[1:] # Remove dot # Store objects before import before = set(bpy.data.objects.keys()) import_start_time = time.time() # Import based on format import_functions = { 'fbx': lambda: bpy.ops.import_scene.fbx( filepath=file_path, axis_forward=axis_forward, axis_up=axis_up, global_scale=scale, use_manual_orientation=True ), 'obj': lambda: bpy.ops.import_scene.obj( filepath=file_path, axis_forward=axis_forward, axis_up=axis_up, global_scale=scale ), 'gltf': lambda: bpy.ops.import_scene.gltf( filepath=file_path ), 'glb': lambda: bpy.ops.import_scene.gltf( filepath=file_path ), 'dae': lambda: bpy.ops.wm.collada_import( filepath=file_path ), 'stl': lambda: bpy.ops.import_mesh.stl( filepath=file_path, global_scale=scale ), 'ply': lambda: bpy.ops.import_mesh.ply( filepath=file_path ), 'abc': lambda: bpy.ops.wm.alembic_import( filepath=file_path, scale=scale ), 'usd': lambda: bpy.ops.wm.usd_import( filepath=file_path, scale=scale ) } file_format = file_format.lower() if file_format not in import_functions: raise ValueError(f"Unsupported format: {file_format}") # Execute import try: import_functions[file_format]() except Exception as e: raise RuntimeError(f"Import failed: {e}") import_time = time.time() - import_start_time # Get imported objects after = set(bpy.data.objects.keys()) imported_objects = list(after - before) # Apply transform if requested if apply_transform and imported_objects: for obj_name in imported_objects: obj = bpy.data.objects.get(obj_name) if obj: obj.select_set(True) bpy.context.view_layer.objects.active = obj bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) # Calculate statistics total_verts = 0 total_faces = 0 for obj_name in imported_objects: obj = bpy.data.objects.get(obj_name) if obj and hasattr(obj.data, 'vertices'): total_verts += len(obj.data.vertices) if obj and hasattr(obj.data, 'polygons'): total_faces += len(obj.data.polygons) file_stats = os.stat(file_path) return { "file_path": file_path, "format": file_format, "imported_objects": imported_objects, "object_count": len(imported_objects), "statistics": { "total_vertices": total_verts, "total_faces": total_faces }, "import_settings": { "axis_forward": axis_forward, "axis_up": axis_up, "scale": scale, "transform_applied": apply_transform }, "file_size": file_stats.st_size, "import_time": import_time, "imported_at": datetime.now().isoformat() } @thread_safe def export_file( file_path: str, file_format: str, selected_only: bool = False, apply_modifiers: bool = True, axis_forward: str = '-Z', axis_up: str = 'Y', scale: float = 1.0, use_mesh_modifiers: bool = True, use_metadata: bool = True ) -> Dict[str, Any]: """ Export with professional options. Args: file_path: Output file path file_format: Export format selected_only: Export only selected objects apply_modifiers: Apply modifiers before export axis_forward: Forward axis for export axis_up: Up axis for export scale: Export scale use_mesh_modifiers: Apply mesh modifiers use_metadata: Include metadata Returns: Complete export information """ # Ensure directory exists os.makedirs(os.path.dirname(file_path), exist_ok=True) # Count objects to export if selected_only: objects_to_export = [o for o in bpy.data.objects if o.select_get()] else: objects_to_export = list(bpy.data.objects) export_start_time = time.time() # Export based on format export_functions = { 'fbx': lambda: bpy.ops.export_scene.fbx( filepath=file_path, use_selection=selected_only, use_mesh_modifiers=use_mesh_modifiers, axis_forward=axis_forward, axis_up=axis_up, global_scale=scale, use_metadata=use_metadata ), 'obj': lambda: bpy.ops.export_scene.obj( filepath=file_path, use_selection=selected_only, use_mesh_modifiers=use_mesh_modifiers, axis_forward=axis_forward, axis_up=axis_up, global_scale=scale ), 'gltf': lambda: bpy.ops.export_scene.gltf( filepath=file_path, use_selection=selected_only, export_apply=apply_modifiers ), 'glb': lambda: bpy.ops.export_scene.gltf( filepath=file_path, use_selection=selected_only, export_apply=apply_modifiers, export_format='GLB' ), 'dae': lambda: bpy.ops.wm.collada_export( filepath=file_path, selected=selected_only, apply_modifiers=apply_modifiers ), 'stl': lambda: bpy.ops.export_mesh.stl( filepath=file_path, use_selection=selected_only, use_mesh_modifiers=use_mesh_modifiers, global_scale=scale ), 'ply': lambda: bpy.ops.export_mesh.ply( filepath=file_path, use_selection=selected_only, use_mesh_modifiers=use_mesh_modifiers, global_scale=scale ), 'abc': lambda: bpy.ops.wm.alembic_export( filepath=file_path, selected=selected_only, apply_subdiv=apply_modifiers, global_scale=scale ), 'usd': lambda: bpy.ops.wm.usd_export( filepath=file_path, selected_objects_only=selected_only, export_materials=True ) } file_format = file_format.lower() if file_format not in export_functions: raise ValueError(f"Unsupported export format: {file_format}") # Execute export try: export_functions[file_format]() except Exception as e: raise RuntimeError(f"Export failed: {e}") export_time = time.time() - export_start_time # Get file statistics file_size = 0 if os.path.exists(file_path): file_size = os.path.getsize(file_path) return { "file_path": file_path, "format": file_format, "objects_exported": len(objects_to_export), "selected_only": selected_only, "settings": { "apply_modifiers": apply_modifiers, "axis_forward": axis_forward, "axis_up": axis_up, "scale": scale }, "file_size": file_size, "file_size_mb": file_size / (1024 * 1024), "export_time": export_time, "exported_at": datetime.now().isoformat() } # ============================================ # ๐Ÿ”— ADVANCED OPERATIONS # ============================================ @thread_safe def boolean_operation( object_a: str, object_b: str, operation: str = "DIFFERENCE", solver: str = "FAST", apply: bool = True, hide_b: bool = True, delete_b: bool = False ) -> Dict[str, Any]: """ Perform boolean operations with advanced options. Args: object_a: First object (target) object_b: Second object (operator) operation: Operation type (UNION, DIFFERENCE, INTERSECT) solver: Solver type (FAST, EXACT) apply: Apply modifier immediately hide_b: Hide second object delete_b: Delete second object after operation Returns: Complete boolean result """ obj_a = bpy.data.objects.get(object_a) obj_b = bpy.data.objects.get(object_b) if not obj_a: raise ValueError(f"Object A '{object_a}' not found") if not obj_b: raise ValueError(f"Object B '{object_b}' not found") # Store initial statistics stats_before = { "vertices": len(obj_a.data.vertices) if hasattr(obj_a.data, 'vertices') else 0, "faces": len(obj_a.data.polygons) if hasattr(obj_a.data, 'polygons') else 0 } # Add boolean modifier modifier = obj_a.modifiers.new(name=f"Boolean_{operation}", type='BOOLEAN') modifier.operation = operation modifier.object = obj_b modifier.solver = solver # Apply if requested if apply: bpy.context.view_layer.objects.active = obj_a obj_a.select_set(True) try: bpy.ops.object.modifier_apply(modifier=modifier.name) except Exception as e: raise RuntimeError(f"Boolean operation failed: {e}") # Handle object B if delete_b: bpy.data.objects.remove(obj_b) elif hide_b: obj_b.hide_set(True) obj_b.hide_render = True # Get final statistics stats_after = { "vertices": len(obj_a.data.vertices) if hasattr(obj_a.data, 'vertices') else 0, "faces": len(obj_a.data.polygons) if hasattr(obj_a.data, 'polygons') else 0 } return { "object_a": object_a, "object_b": object_b if not delete_b else "deleted", "operation": operation, "solver": solver, "applied": apply, "statistics": { "before": stats_before, "after": stats_after, "difference": { "vertices": stats_after["vertices"] - stats_before["vertices"], "faces": stats_after["faces"] - stats_before["faces"] } }, "object_b_status": "deleted" if delete_b else ("hidden" if hide_b else "visible"), "completed_at": datetime.now().isoformat() } # ============================================ # ๐Ÿš€ SERVER MANAGEMENT # ============================================ def create_blender_mcp_server(): """ Create production-ready Blender MCP server with all functions. Returns: FastAPI app configured for complete Blender control """ # Complete list of all production tools all_tools = [ # SPATIAL AWARENESS TOOLS get_object_position, calculate_position_relative_to, get_all_objects_positions, find_empty_space, align_objects_in_grid, # Object Creation create_mesh_object, create_curve_object, create_text_object, # Object Manipulation transform_object, duplicate_object, delete_objects, # Modifiers add_modifier, apply_modifier, # Materials & Shading create_material, assign_material, # Lighting create_light, # Camera create_camera, # Animation create_keyframe, # Rendering configure_render_settings, render_image, # File Operations import_file, export_file, save_blend_file, # Scene Management get_scene_info, clear_scene, # Advanced Operations boolean_operation, create_particle_system, add_force_field, # Geometry Nodes add_geometry_nodes, create_procedural_geometry, # UV & Texturing unwrap_uv, add_texture_paint_slots, # Batch Operations batch_create_objects, batch_transform, # Simulations setup_rigid_body, add_cloth_simulation, setup_fluid_simulation, add_fluid_flow, # Advanced Nodes create_shader_node_tree, create_procedural_material, # Templates create_from_template, # Grease Pencil create_grease_pencil_drawing, # Performance optimize_scene, clear_cache, # Presets quick_scene_setup, create_hdri_environment, # Additional Advanced Tools set_optimal_camera_for_all, auto_arrange_objects, verify_last_operation, analyze_spatial_layout, capture_viewport_image ] # Create MCP server app = expose_tools( tools=all_tools, title="Blender MCP Server - Production", description="Production-ready MCP server for complete Blender control", version="3.0.0" ) logger.info(f"MCP Server created with {len(all_tools)} production tools") return app # ============================================ # ๐ŸŽฎ BLENDER ADDON INTERFACE # ============================================ # Global server instance server_thread = None server_app = None class MCPSERVER_PT_main_panel(bpy.types.Panel): """Main MCP Server Panel""" bl_label = "MCP Server Control" bl_idname = "MCPSERVER_PT_main_panel" bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "MCP Server" def draw(self, context): layout = self.layout # Server status box = layout.box() box.label(text="Server Status", icon='WORLD_DATA') row = box.row(align=True) if server_thread is None or not server_thread.is_alive(): row.operator("mcp.start_server", text="Start Server", icon='PLAY') else: row.operator("mcp.stop_server", text="Stop Server", icon='PAUSE') col = box.column() col.label(text="Server running on:", icon='URL') col.label(text=f"http://localhost:{Config.PORT}") row = col.row() row.operator("wm.url_open", text="API Docs").url = f"http://localhost:{Config.PORT}/docs" row.operator("wm.url_open", text="List Tools").url = f"http://localhost:{Config.PORT}/mcp/list_tools" # Server info box = layout.box() box.label(text="Server Information", icon='INFO') col = box.column(align=True) col.label(text=f"Version: 3.0.0") col.label(text=f"Thread-Safe: {'Yes' if Config.THREAD_SAFE_OPERATIONS else 'No'}") col.label(text=f"Port: {Config.PORT}") # Quick stats box = layout.box() box.label(text="Scene Statistics", icon='SCENE_DATA') col = box.column(align=True) col.label(text=f"Objects: {len(bpy.data.objects)}") col.label(text=f"Materials: {len(bpy.data.materials)}") col.label(text=f"Textures: {len(bpy.data.images)}") col.label(text=f"Frame: {context.scene.frame_current}/{context.scene.frame_end}") class MCPSERVER_OT_start_server(bpy.types.Operator): """Start MCP Server""" bl_idname = "mcp.start_server" bl_label = "Start MCP Server" bl_options = {'REGISTER'} def execute(self, context): global server_thread, server_app try: bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() # Avvia il processore di code start_queue_processor() # Create MCP server app server_app = create_blender_mcp_server() # Run in separate thread def run_server(): uvicorn.run(server_app, host="0.0.0.0", port=8000) server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() self.report({'INFO'}, "MCP Server started on http://localhost:8000") except Exception as e: self.report({'ERROR'}, f"Failed to start server: {str(e)}") return {'FINISHED'} class MCPSERVER_OT_start_server(bpy.types.Operator): """Start MCP Server""" bl_idname = "mcp.start_server" bl_label = "Start MCP Server" bl_options = {'REGISTER'} def execute(self, context): global server_thread, server_app try: # Start thread-safe executor thread_executor.start() bpy.ops.object.select_all(action='SELECT') bpy.ops.object.delete() # Create MCP server app server_app = create_blender_mcp_server() # Run in separate thread def run_server(): uvicorn.run( server_app, host=Config.HOST, port=Config.PORT, log_level="info" ) server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() self.report({'INFO'}, f"MCP Server started on http://localhost:{Config.PORT}") logger.info(f"MCP Server started successfully on port {Config.PORT}") except Exception as e: self.report({'ERROR'}, f"Failed to start server: {str(e)}") logger.error(f"Failed to start server: {e}") logger.exception(e) return {'FINISHED'} class MCPSERVER_OT_stop_server(bpy.types.Operator): """Stop MCP Server""" bl_idname = "mcp.stop_server" bl_label = "Stop MCP Server" bl_options = {'REGISTER'} def execute(self, context): global server_thread # Stop thread-safe executor thread_executor.stop() # Stop server thread server_thread = None self.report({'INFO'}, "MCP Server stopped") logger.info("MCP Server stopped") return {'FINISHED'} # Registration classes = [ MCPSERVER_PT_main_panel, MCPSERVER_OT_start_server, MCPSERVER_OT_stop_server, ] def register(): for cls in classes: bpy.utils.register_class(cls) logger.info("MCP Server addon registered") def unregister(): # Stop server if running if server_thread and server_thread.is_alive(): thread_executor.stop() for cls in classes: bpy.utils.unregister_class(cls) logger.info("MCP Server addon unregistered") if __name__ == "__main__": register()

MCP directory API

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

curl -X GET 'https://glama.ai/api/mcp/v1/servers/llm-use/Blender-MCP-Server'

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