Skip to main content
Glama

Cinema4D MCP Server

by ttiimmaacc
""" Cinema 4D MCP Server Plugin Updated for Cinema 4D R2025 compatibility Version 0.1.8 - Context awareness """ import c4d from c4d import gui import socket import threading import json import time import math import queue import os import sys import base64 import traceback PLUGIN_ID = 1057843 # Unique plugin ID for SpecialEventAdd # Check Cinema 4D version and log compatibility info C4D_VERSION = c4d.GetC4DVersion() C4D_VERSION_MAJOR = C4D_VERSION // 1000 C4D_VERSION_MINOR = (C4D_VERSION // 100) % 10 print(f"[C4D MCP] Running on Cinema 4D R{C4D_VERSION_MAJOR}{C4D_VERSION_MINOR}") print(f"[C4D MCP] Python version: {sys.version}") # Warn if using unsupported version if C4D_VERSION_MAJOR < 20: print( "[C4D MCP] ## Warning ##: This plugin is in development for Cinema 4D 2025 or later with plans to futher support earlier versions. Some features may not work correctly." ) class C4DSocketServer(threading.Thread): """Socket Server running in a background thread, sending logs & status via queue.""" def __init__(self, msg_queue, host="127.0.0.1", port=5555): super(C4DSocketServer, self).__init__() self.host = host self.port = port self.socket = None self.running = False self.msg_queue = msg_queue # Queue to communicate with UI self.daemon = True # Ensures cleanup on shutdown # --- ADDED FOR CONTEXT AWARENESS --- self._object_name_registry = ( {} ) # OLDs? --Maps GUID -> requested_name AND requested_name -> GUID (less robust) self._name_to_guid_registry = ( {} ) # Maps requested_name.lower() or actual_name.lower() -> guid self._guid_to_name_registry = ( {} ) # Maps guid -> {'requested_name': str, 'actual_name': str} def log(self, message): """Send log messages to UI via queue and trigger an event.""" self.msg_queue.put(("LOG", message)) c4d.SpecialEventAdd(PLUGIN_ID) # Notify UI thread def update_status(self, status): """Update status via queue and trigger an event.""" self.msg_queue.put(("STATUS", status)) c4d.SpecialEventAdd(PLUGIN_ID) def execute_on_main_thread(self, func, args=None, kwargs=None, _timeout=None): """Execute a function on the main thread using a thread-safe queue and special event. Since CallMainThread is not available in the Python SDK (R2025), we use a thread-safe approach by queuing the function and triggering it via SpecialEventAdd. Args: func: The function to execute on the main thread *args: Arguments to pass to the function **kwargs: Keyword arguments to pass to the function Special keyword '_timeout': Override default timeout (in seconds) Returns: The result of executing the function on the main thread """ args = args or () kwargs = kwargs or {} # Extract the timeout parameter if provided, or use default timeout = kwargs.pop("_timeout", None) # Set appropriate timeout based on operation type if timeout is None: # Use different default timeouts based on the function name func_name = func.__name__ if hasattr(func, "__name__") else str(func) if "render" in func_name.lower(): timeout = 120 # 2 minutes for rendering self.log(f"[C4D] Using extended timeout (120s) for rendering operation") elif "save" in func_name.lower(): timeout = 60 # 1 minute for saving self.log(f"[C4D] Using extended timeout (60s) for save operation") elif "field" in func_name.lower(): timeout = 30 # 30 seconds for field operations self.log(f"[C4D] Using extended timeout (30s) for field operation") else: timeout = 15 # Default timeout increased to 15 seconds self.log(f"[C4D] Main thread execution will timeout after {timeout}s") # Create a thread-safe container for the result result_container = {"result": None, "done": False} # Define a wrapper that will be executed on the main thread def main_thread_exec(): try: self.log( f"[C4D] Starting main thread execution of {func.__name__ if hasattr(func, '__name__') else 'function'}" ) start_time = time.time() result_container["result"] = func(*args, **kwargs) execution_time = time.time() - start_time self.log( f"[C4D] Main thread execution completed in {execution_time:.2f}s" ) except Exception as e: self.log( f"[**ERROR**] Error executing function on main thread: {str(e)}" ) result_container["result"] = {"error": str(e)} finally: result_container["done"] = True return True # Queue the request and signal the main thread self.log("[C4D] Queueing function for main thread execution") self.msg_queue.put(("EXEC", main_thread_exec)) c4d.SpecialEventAdd(PLUGIN_ID) # Notify UI thread # Wait for the function to complete (with timeout) start_time = time.time() poll_interval = 0.01 # Small sleep to prevent CPU overuse progress_interval = 1.0 # Log progress every second last_progress = 0 while not result_container["done"]: time.sleep(poll_interval) # Calculate elapsed time elapsed = time.time() - start_time # Log progress periodically for long-running operations if int(elapsed) > last_progress: if elapsed > 5: # Only start logging after 5 seconds self.log( f"[C4D] Waiting for main thread execution ({elapsed:.1f}s elapsed)" ) last_progress = int(elapsed) # Check for timeout if elapsed > timeout: self.log(f"[C4D] Main thread execution timed out after {elapsed:.2f}s") return {"error": f"Execution on main thread timed out after {timeout}s"} # Improved result handling if result_container["result"] is None: self.log( "[C4D] ## Warning ##: Function execution completed but returned None" ) # Return a structured response instead of None return { "status": "completed", "result": None, "warning": "Function returned None", } return result_container["result"] def run(self): """Main server loop""" try: self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.bind((self.host, self.port)) self.socket.listen(5) self.running = True self.update_status("Online") self.log(f"[C4D] Server started on {self.host}:{self.port}") while self.running: client, addr = self.socket.accept() self.log(f"[C4D] Client connected from {addr}") threading.Thread(target=self.handle_client, args=(client,)).start() except Exception as e: self.log(f"[C4D] Server Error: {str(e)}") self.update_status("Offline") self.running = False def handle_client(self, client): """Handle incoming client connections.""" buffer = "" try: while self.running: data = client.recv(4096) if not data: break # Add received data to buffer buffer += data.decode("utf-8") # Process complete messages (separated by newlines) while "\n" in buffer: message, buffer = buffer.split("\n", 1) self.log(f"[C4D] Received: {message}") try: # Parse the command command = json.loads(message) command_type = command.get("command", "") # Scene info & execution if command_type == "get_scene_info": response = self.handle_get_scene_info() elif command_type == "list_objects": response = self.handle_list_objects() elif command_type == "group_objects": response = self.handle_group_objects(command) elif command_type == "execute_python": response = self.handle_execute_python(command) elif command_type == "save_scene": response = self.handle_save_scene(command) elif command_type == "load_scene": response = self.handle_load_scene(command) elif command_type == "set_keyframe": response = self.handle_set_keyframe(command) # Object creation & modification elif command_type == "add_primitive": response = self.handle_add_primitive(command) elif command_type == "modify_object": response = self.handle_modify_object(command) elif command_type == "create_abstract_shape": response = self.handle_create_abstract_shape(command) # Materials & shaders elif command_type == "create_material": response = self.handle_create_material(command) elif command_type == "apply_material": response = self.handle_apply_material(command) elif command_type == "apply_shader": response = self.handle_apply_shader(command) elif command_type == "validate_redshift_materials": response = self.handle_validate_redshift_materials(command) # Rendering & preview elif command_type == "render_frame": response = self.handle_render_frame(command) elif command_type == "render_preview": response = self.handle_render_preview_base64() elif command_type == "snapshot_scene": response = self.handle_snapshot_scene(command) # Camera & light handling elif command_type == "create_camera": response = self.handle_create_camera(command) elif command_type == "animate_camera": response = self.handle_animate_camera(command) elif command_type == "create_light": response = self.handle_create_light(command) # MoGraph/dynamics elif command_type == "create_mograph_cloner": response = self.handle_create_mograph_cloner(command) elif command_type == "add_effector": response = self.handle_add_effector(command) elif command_type == "apply_mograph_fields": response = self.handle_apply_mograph_fields(command) elif command_type == "create_soft_body": response = self.handle_create_soft_body(command) elif command_type == "apply_dynamics": response = self.handle_apply_dynamics(command) else: response = {"error": f"Unknown command: {command_type}"} # Send the response as JSON response_json = json.dumps(response) + "\n" client.sendall(response_json.encode("utf-8")) self.log(f"[C4D] Sent response for {command_type}") except json.JSONDecodeError: error_response = {"error": "Invalid JSON format"} client.sendall( (json.dumps(error_response) + "\n").encode("utf-8") ) except Exception as e: error_response = { "error": f"Error processing command: {str(e)}" } client.sendall( (json.dumps(error_response) + "\n").encode("utf-8") ) self.log(f"[**ERROR**] Error processing command: {str(e)}") except Exception as e: self.log(f"[C4D] Client error: {str(e)}") finally: client.close() self.log("[C4D] Client disconnected") def stop(self): """Stop the server.""" self.running = False if self.socket: self.socket.close() self.update_status("Offline") self.log("[C4D] Server stopped") # Basic commands def handle_get_scene_info(self): """Handle get_scene_info command.""" doc = c4d.documents.GetActiveDocument() # Get scene information scene_info = { "filename": doc.GetDocumentName() or "Untitled", "object_count": self.count_objects(doc), "polygon_count": self.count_polygons(doc), "material_count": len(doc.GetMaterials()), "current_frame": doc.GetTime().GetFrame(doc.GetFps()), "fps": doc.GetFps(), "frame_start": doc.GetMinTime().GetFrame(doc.GetFps()), "frame_end": doc.GetMaxTime().GetFrame(doc.GetFps()), } return {"scene_info": scene_info} def count_objects(self, doc): """Count all objects in the document.""" count = 0 obj = doc.GetFirstObject() while obj: count += 1 obj = obj.GetNext() return count def count_polygons(self, doc): """Count all polygons in the document.""" count = 0 obj = doc.GetFirstObject() while obj: if obj.GetType() == c4d.Opolygon: count += obj.GetPolygonCount() obj = obj.GetNext() return count def get_object_type_name(self, obj): """Get a human-readable object type name.""" type_id = obj.GetType() # Expanded type map including MoGraph objects type_map = { c4d.Ocube: "Cube", c4d.Osphere: "Sphere", c4d.Ocone: "Cone", c4d.Ocylinder: "Cylinder", c4d.Odisc: "Disc", c4d.Ocapsule: "Capsule", c4d.Otorus: "Torus", c4d.Otube: "Tube", c4d.Oplane: "Plane", c4d.Olight: "Light", c4d.Ocamera: "Camera", c4d.Onull: "Null", c4d.Opolygon: "Polygon Object", c4d.Ospline: "Spline", c4d.Omgcloner: "MoGraph Cloner", # MoGraph Cloner } # Check for MoGraph objects using ranges if 1018544 <= type_id <= 1019544: # MoGraph objects general range if type_id == c4d.Omgcloner: return "MoGraph Cloner" elif type_id == c4d.Omgtext: return "MoGraph Text" elif type_id == c4d.Omgtracer: return "MoGraph Tracer" elif type_id == c4d.Omgmatrix: return "MoGraph Matrix" else: return "MoGraph Object" # MoGraph Effectors if 1019544 <= type_id <= 1019644: if type_id == c4d.Omgrandom: return "Random Effector" elif type_id == c4d.Omgstep: return "Step Effector" elif type_id == c4d.Omgformula: return "Formula Effector" else: return "MoGraph Effector" # Fields (newer Cinema 4D versions) if 1039384 <= type_id <= 1039484: field_types = { 1039384: "Spherical Field", 1039385: "Box Field", 1039386: "Cylindrical Field", 1039387: "Torus Field", 1039388: "Cone Field", 1039389: "Linear Field", 1039390: "Radial Field", 1039394: "Noise Field", } return field_types.get(type_id, "Field") return type_map.get(type_id, f"Object (Type: {type_id})") def find_object_by_name(self, doc, name_or_guid, use_guid=False): """Find object by GUID (preferred) or name, using local registry first. FIX for recursion and GUID format check.""" if not name_or_guid: self.log("[C4D FIND] Cannot find object: No name or GUID provided.") return None if not doc: self.log("[C4D FIND] ## Error ##: No document provided for search.") return None if not hasattr(self, "_name_to_guid_registry"): self._name_to_guid_registry = {} if not hasattr(self, "_guid_to_name_registry"): self._guid_to_name_registry = {} search_term = str(name_or_guid).strip() self.log( f"[C4D FIND] Attempting to find: '{search_term}' (Treat as GUID: {use_guid})" ) # --- GUID Search Logic --- if use_guid: guid_to_find = search_term # --- FIXED: GUID format check --- # C4D GUIDs converted with str() are typically long numbers (sometimes negative). # Check if it's likely numeric and long enough. Hyphen is NOT required. is_valid_guid_format = False if guid_to_find: # Check if not empty try: int(guid_to_find) # Check if it can be interpreted as an integer if len(guid_to_find) > 10: # Check if it's reasonably long is_valid_guid_format = True except ValueError: is_valid_guid_format = False # Not purely numeric # --- END FIXED --- if not is_valid_guid_format: self.log( f"[C4D FIND] ## Warning ##: Invalid format/length for GUID search: '{guid_to_find}'. Treating as name." ) use_guid = False # Fallback to name search else: # 1. Try direct C4D SearchObject obj_from_search = doc.SearchObject(guid_to_find) if obj_from_search: self.log( f"[C4D FIND] Success (GUID Scene Search): Found '{obj_from_search.GetName()}' (GUID: {guid_to_find})" ) current_actual_name = obj_from_search.GetName() reg_entry = self._guid_to_name_registry.get(guid_to_find) if ( not reg_entry or reg_entry.get("actual_name") != current_actual_name ): req_name = ( reg_entry.get("requested_name", current_actual_name) if reg_entry else current_actual_name ) self.register_object_name(obj_from_search, req_name) return obj_from_search # 2. Manual iteration fallback self.log( f"[C4D FIND] Info: doc.SearchObject failed for GUID {guid_to_find}. Iterating manually..." ) all_objects = self._get_all_objects(doc) found_obj_manual = None for obj_iter in all_objects: try: iter_guid = str(obj_iter.GetGUID()) if iter_guid == guid_to_find: self.log( f"[C4D FIND] Success (GUID Manual Iteration): Found '{obj_iter.GetName()}' (GUID: {guid_to_find})" ) found_obj_manual = obj_iter break except Exception as e_iter: self.log( f"[C4D FIND] Error checking GUID during iteration for '{obj_iter.GetName()}': {e_iter}" ) if found_obj_manual: current_actual_name = found_obj_manual.GetName() reg_entry = self._guid_to_name_registry.get(guid_to_find) if ( reg_entry and reg_entry.get("actual_name") != current_actual_name ): req_name = reg_entry.get("requested_name", current_actual_name) self.register_object_name(found_obj_manual, req_name) elif not reg_entry: self.register_object_name( found_obj_manual, found_obj_manual.GetName() ) return found_obj_manual # 3. If both failed, cleanup registry self.log( f"[C4D FIND] Failed (GUID): Object with GUID '{guid_to_find}' not found by SearchObject or Manual Iteration." ) if guid_to_find in self._guid_to_name_registry: self.log( f"[C4D FIND] Cleaning registry for supposedly existing but unfound GUID {guid_to_find}." ) reg_entry = self._guid_to_name_registry.pop(guid_to_find, None) if reg_entry: req_name_lower = reg_entry.get("requested_name", "").lower() act_name_lower = reg_entry.get("actual_name", "").lower() if req_name_lower: self._name_to_guid_registry.pop(req_name_lower, None) if act_name_lower and act_name_lower != req_name_lower: self._name_to_guid_registry.pop(act_name_lower, None) return None # --- Name Search Logic (Keep as is from previous correction) --- name_to_find_lower = search_term.lower() # 1. Check registry by name -> GUID -> Object guid_from_registry = self._name_to_guid_registry.get(name_to_find_lower) if guid_from_registry: obj_from_guid_lookup = self.find_object_by_name( doc, guid_from_registry, use_guid=True ) if obj_from_guid_lookup: stored_names = self._guid_to_name_registry.get(guid_from_registry, {}) actual_name_reg = stored_names.get("actual_name", "").lower() requested_name_reg = stored_names.get("requested_name", "").lower() found_name_actual = obj_from_guid_lookup.GetName().lower() if name_to_find_lower in [ actual_name_reg, requested_name_reg, found_name_actual, ]: self.log( f"[C4D FIND] Success (Registry Name '{search_term}' -> GUID {guid_from_registry}): Found '{obj_from_guid_lookup.GetName()}'" ) if found_name_actual != actual_name_reg: self.register_object_name( obj_from_guid_lookup, stored_names.get("requested_name", search_term), ) return obj_from_guid_lookup else: self.log( f"[C4D FIND] ## Warning ## Registry inconsistency for name '{search_term}'. Continuing search." ) else: self.log( f"[C4D FIND] ## Warning ## Name '{search_term}' maps to non-existent GUID. Cleaning registry." ) self._name_to_guid_registry.pop(name_to_find_lower, None) reg_entry = self._guid_to_name_registry.pop(guid_from_registry, None) if reg_entry: other_name_key = ( "actual_name" if name_to_find_lower == reg_entry.get("requested_name", "").lower() else "requested_name" ) other_name_val = reg_entry.get(other_name_key, "").lower() if other_name_val: self._name_to_guid_registry.pop(other_name_val, None) # 2. Direct name search all_objects_name = self._get_all_objects(doc) for obj in all_objects_name: if obj.GetName().strip().lower() == name_to_find_lower: self.log( f"[C4D FIND] Success (Direct Name Search): Found '{obj.GetName()}'" ) self.register_object_name(obj, search_term) return obj # 3. Comment Tag Search self.log(f"[C4D FIND] Trying comment tag search for '{search_term}'") if hasattr(c4d, "Tcomment"): for obj in all_objects_name: for tag in obj.GetTags(): if tag.GetType() == c4d.Tcomment: try: tag_text = tag[c4d.COMMENTTAG_TEXT] if tag_text and tag_text.startswith("MCP_NAME:"): tagged_name = tag_text[9:].strip() if tagged_name.lower() == name_to_find_lower: self.log( f"[C4D FIND] Success (Comment Tag): Found '{obj.GetName()}'" ) self.register_object_name(obj, search_term) return obj except Exception as e: self.log(f"Error reading comment tag: {e}") # 4. User Data Search self.log(f"[C4D FIND] Trying user data search for '{search_term}'") for obj in all_objects_name: try: userdata = obj.GetUserDataContainer() if userdata: for i in range(len(userdata)): desc_id_tuple = obj.GetUserDataContainer()[i] if ( isinstance(desc_id_tuple, tuple) and len(desc_id_tuple) > c4d.DESC_NAME ): if desc_id_tuple[c4d.DESC_NAME] == "mcp_original_name": desc_id = desc_id_tuple[c4d.DESC_ID] if obj[desc_id].strip().lower() == name_to_find_lower: self.log( f"[C4D FIND] Success (User Data): Found '{obj.GetName()}'" ) self.register_object_name(obj, search_term) return obj except Exception as e: self.log(f"Error checking user data for '{obj.GetName()}': {e}") # 5. Fuzzy Name Search self.log(f"[C4D FIND] Trying fuzzy name matching for '{search_term}'") similar_objects = [] for obj in all_objects_name: obj_name_lower = obj.GetName().strip().lower() if ( name_to_find_lower in obj_name_lower or obj_name_lower in name_to_find_lower or obj_name_lower.startswith(name_to_find_lower) or name_to_find_lower.startswith(obj_name_lower) ): similarity = abs(len(obj_name_lower) - len(name_to_find_lower)) similar_objects.append((obj, similarity)) if similar_objects: similar_objects.sort(key=lambda pair: pair[1]) closest_match = similar_objects[0][0] self.log( f"[C4D FIND] Success (Fuzzy Fallback): Using '{closest_match.GetName()}' for '{search_term}'" ) self.register_object_name(closest_match, search_term) return closest_match # Final failure self.log( f"[C4D FIND] Failed: Object '{search_term}' not found after all checks." ) return None def _get_all_objects(self, doc): """Recursively collects all objects in the scene into a flat list.""" result = [] def collect_recursive(obj): while obj: result.append(obj) if obj.GetDown(): collect_recursive(obj.GetDown()) obj = obj.GetNext() first_obj = doc.GetFirstObject() if first_obj: collect_recursive(first_obj) return result def get_all_objects_comprehensive(self, doc): """Get all objects in the document using multiple methods to ensure complete coverage. This method is specifically designed to catch objects that might be missed by standard GetFirstObject()/GetNext() iteration, particularly MoGraph objects. Args: doc: The Cinema 4D document to search Returns: List of all objects found """ all_objects = [] found_ids = set() # Method 1: Standard traversal using GetFirstObject/GetNext/GetDown self.log("[C4D] Comprehensive search - using standard traversal") def traverse_hierarchy(obj): while obj: try: obj_id = str(obj.GetGUID()) if obj_id not in found_ids: all_objects.append(obj) found_ids.add(obj_id) # Check children child = obj.GetDown() if child: traverse_hierarchy(child) except Exception as e: self.log(f"[**ERROR**] Error in hierarchy traversal: {str(e)}") # Move to next sibling obj = obj.GetNext() # Start traversal from the first object first_obj = doc.GetFirstObject() if first_obj: traverse_hierarchy(first_obj) # Method 2: Use GetObjects() for flat list (catches some objects) try: self.log("[C4D] Comprehensive search - using GetObjects()") flat_objects = doc.GetObjects() for obj in flat_objects: obj_id = str(obj.GetGUID()) if obj_id not in found_ids: all_objects.append(obj) found_ids.add(obj_id) except Exception as e: self.log(f"[**ERROR**] Error in GetObjects search: {str(e)}") # Method 3: Special handling for MoGraph objects try: self.log("[C4D] Comprehensive search - direct access for MoGraph") # Direct check for Cloners if hasattr(c4d, "Omgcloner"): # Try using FindObjects if available (R20+) if hasattr(c4d.BaseObject, "FindObjects"): cloners = c4d.BaseObject.FindObjects(doc, c4d.Omgcloner) for cloner in cloners: obj_id = str(cloner.GetGUID()) if obj_id not in found_ids: all_objects.append(cloner) found_ids.add(obj_id) self.log( f"[C4D] Found cloner using FindObjects: {cloner.GetName()}" ) # Check for other MoGraph objects if needed # (Add specific searches here if certain objects are still missed) except Exception as e: self.log(f"[**ERROR**] Error in MoGraph direct search: {str(e)}") self.log( f"[C4D] Comprehensive object search complete, found {len(all_objects)} objects" ) return all_objects def handle_group_objects(self, command): """Handle group_objects command with GUID support.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} requested_group_name = command.get("group_name", "Group") object_identifiers = command.get("object_names", []) position = command.get("position", None) center = command.get("center", False) keep_world_pos = command.get("keep_world_position", True) objects_to_group = [] identifiers_found_guids = set() identifiers_not_found = [] if object_identifiers: self.log(f"[GROUP] Received identifiers: {object_identifiers}") for identifier in object_identifiers: if not identifier: continue identifier_str = str(identifier).strip() # --- REVISED: Detect GUID format correctly --- use_current_id_as_guid = False if "-" in identifier_str and len(identifier_str) > 30: use_current_id_as_guid = True elif identifier_str.isdigit() or ( identifier_str.startswith("-") and identifier_str[1:].isdigit() ): if len(identifier_str) > 10: use_current_id_as_guid = True # --- END REVISED --- self.log( f"[GROUP] Finding object by identifier: '{identifier_str}' (Treat as GUID: {use_current_id_as_guid})" ) # --- Pass the correct flag to find_object_by_name --- obj = self.find_object_by_name( doc, identifier_str, use_guid=use_current_id_as_guid ) if obj: obj_guid = str(obj.GetGUID()) if obj_guid not in identifiers_found_guids: objects_to_group.append(obj) identifiers_found_guids.add(obj_guid) self.log( f"[GROUP] Found object: '{obj.GetName()}' (GUID: {obj_guid})" ) else: self.log( f"[GROUP] Info: Object '{obj.GetName()}' (GUID: {obj_guid}) already added." ) else: self.log( f"[GROUP] ## Warning ##: Object identifier not found: '{identifier_str}' (Searched as GUID: {use_current_id_as_guid})" ) identifiers_not_found.append(identifier_str) else: objects_to_group = doc.GetActiveObjects( c4d.GETACTIVEOBJECTFLAGS_SELECTIONORDER | c4d.GETACTIVEOBJECTFLAGS_TOPLEVEL ) if not objects_to_group: return { "error": "No objects selected (top-level) or specified via 'object_names'." } self.log( f"[GROUP] Fallback: Grouping {len(objects_to_group)} selected top-level objects." ) if not objects_to_group: error_msg = "No valid objects found to group." if identifiers_not_found: error_msg += f" Identifiers not found: {identifiers_not_found}" return {"error": error_msg} # --- Grouping Logic --- group_null = None try: doc.StartUndo() group_null = c4d.BaseObject(c4d.Onull) group_null.SetName(requested_group_name) doc.InsertObject(group_null, None, None) doc.AddUndo(c4d.UNDOTYPE_NEW, group_null) grouped_actual_names = [] grouped_guids = [] original_matrices = {} # Calculate center group_center_pos = c4d.Vector(0) # ... (keep centering logic as before) ... if center: min_vec, max_vec = c4d.Vector(float("inf")), c4d.Vector(float("-inf")) count = 0 for obj in objects_to_group: try: rad, mp = obj.GetRad(), obj.GetMp() min_vec.x, min_vec.y, min_vec.z = ( min(min_vec.x, mp.x - rad.x), min(min_vec.y, mp.y - rad.y), min(min_vec.z, mp.z - rad.z), ) max_vec.x, max_vec.y, max_vec.z = ( max(max_vec.x, mp.x + rad.x), max(max_vec.y, mp.y + rad.y), max(max_vec.z, mp.z + rad.z), ) count += 1 except Exception as e_bounds: self.log( f"[GROUP] Warning: Error getting bounds for '{obj.GetName()}': {e_bounds}" ) if count > 0: group_center_pos = (min_vec + max_vec) * 0.5 self.log(f"[GROUP] Calculated center for null: {group_center_pos}") else: center = False self.log( "[GROUP] Warning: Could not calculate center, disabling centering." ) # Reparent for obj in reversed(objects_to_group): try: obj_name = obj.GetName() obj_guid = str(obj.GetGUID()) grouped_actual_names.append(obj_name) grouped_guids.append(obj_guid) if keep_world_pos: original_matrices[obj_guid] = obj.GetMg() obj.Remove() obj.InsertUnder(group_null) doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj) except Exception as e_reparent: self.log( f"[**ERROR**] Failed to reparent object '{obj_name}': {e_reparent}" ) # Set Position if isinstance(position, list) and len(position) == 3: try: target_pos = c4d.Vector( float(position[0]), float(position[1]), float(position[2]) ) group_null.SetAbsPos(target_pos) doc.AddUndo(c4d.UNDOTYPE_CHANGE, group_null) except (ValueError, TypeError) as e_pos: self.log( f"[GROUP] Warning: Invalid position value '{position}': {e_pos}" ) elif center: group_null.SetAbsPos(group_center_pos) doc.AddUndo(c4d.UNDOTYPE_CHANGE, group_null) # Adjust children if keep_world_pos: null_mg_inv = ~group_null.GetMg() for child in group_null.GetChildren(): child_guid = str(child.GetGUID()) if child_guid in original_matrices: new_ml = null_mg_inv * original_matrices[child_guid] child.SetMl(new_ml) doc.AddUndo(c4d.UNDOTYPE_CHANGE, child) else: self.log( f"[GROUP] ## Warning ## Original matrix not found for child '{child.GetName()}'." ) doc.EndUndo() c4d.EventAdd() # --- Contextual Return --- actual_group_name = group_null.GetName() group_guid = str(group_null.GetGUID()) pos_vector = group_null.GetAbsPos() self.register_object_name(group_null, requested_group_name) response = { "group": { "requested_name": requested_group_name, "actual_name": actual_group_name, "guid": group_guid, "children_actual_names": grouped_actual_names, "children_guids": grouped_guids, "position": [pos_vector.x, pos_vector.y, pos_vector.z], "centered": center, "kept_world_position": keep_world_pos, } } if identifiers_not_found: response["warnings"] = [ f"Object identifier not found: '{idf}'" for idf in identifiers_not_found ] return response except Exception as e: doc.EndUndo() error_msg = f"Error during grouping: {str(e)}" self.log(f"[**ERROR**] {error_msg}\n{traceback.format_exc()}") if group_null and group_null.GetDown() is None: try: doc.AddUndo(c4d.UNDOTYPE_DELETE, group_null) group_null.Remove() except: pass return {"error": error_msg, "traceback": traceback.format_exc()} def handle_add_primitive(self, command): """Handle add_primitive command.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # Added check primitive_type = command.get("primitive_type") or command.get("type") or "cube" primitive_type = primitive_type.lower() # Use provided name or generate one requested_name = ( command.get("name") or command.get("object_name") or f"MCP_{primitive_type.capitalize()}_{int(time.time()) % 1000}" # Generate unique name ) position_list = command.get("position", [0, 0, 0]) size_list = command.get("size", [50, 50, 50]) # Default size # --- Safely parse position and size --- position = [0.0, 0.0, 0.0] if isinstance(position_list, list) and len(position_list) >= 3: try: position = [float(p) for p in position_list[:3]] except (ValueError, TypeError): self.log(f"Warning: Invalid position data {position_list}") else: self.log(f"Warning: Position data not a list of 3: {position_list}") size = [50.0, 50.0, 50.0] if isinstance(size_list, list) and len(size_list) > 0: try: size_raw = [float(s) for s in size_list if s is not None] if not size_raw: raise ValueError("Empty size list after filtering None") sx = size_raw[0] sy = size_raw[1] if len(size_raw) > 1 else sx sz = size_raw[2] if len(size_raw) > 2 else sx size = [sx, sy, sz] except (ValueError, TypeError): self.log(f"Warning: Invalid size data {size_list}") elif isinstance(size_list, (int, float)): # Allow single size value size = [float(size_list)] * 3 else: self.log(f"Warning: Size data not a list or number: {size_list}") # --- End safe parse --- obj = None try: # Wrap object creation/setting in try-except # Create the appropriate primitive object if primitive_type == "cube": obj = c4d.BaseObject(c4d.Ocube) obj[c4d.PRIM_CUBE_LEN] = c4d.Vector(*size) elif primitive_type == "sphere": obj = c4d.BaseObject(c4d.Osphere) obj[c4d.PRIM_SPHERE_RAD] = size[0] / 2.0 # Use float division elif primitive_type == "cone": obj = c4d.BaseObject(c4d.Ocone) obj[c4d.PRIM_CONE_TRAD] = 0 obj[c4d.PRIM_CONE_BRAD] = size[0] / 2.0 obj[c4d.PRIM_CONE_HEIGHT] = size[1] elif primitive_type == "cylinder": obj = c4d.BaseObject(c4d.Ocylinder) obj[c4d.PRIM_CYLINDER_RADIUS] = size[0] / 2.0 obj[c4d.PRIM_CYLINDER_HEIGHT] = size[1] elif primitive_type == "plane": obj = c4d.BaseObject(c4d.Oplane) obj[c4d.PRIM_PLANE_WIDTH] = size[0] obj[c4d.PRIM_PLANE_HEIGHT] = size[1] elif primitive_type == "pyramid": obj = c4d.BaseObject(c4d.Opyramid) if hasattr(c4d, "PRIM_PYRAMID_LEN"): obj[c4d.PRIM_PYRAMID_LEN] = c4d.Vector(*size) else: if hasattr(c4d, "PRIM_PYRAMID_WIDTH"): obj[c4d.PRIM_PYRAMID_WIDTH] = size[0] if hasattr(c4d, "PRIM_PYRAMID_HEIGHT"): obj[c4d.PRIM_PYRAMID_HEIGHT] = size[1] if hasattr(c4d, "PRIM_PYRAMID_DEPTH"): obj[c4d.PRIM_PYRAMID_DEPTH] = size[2] elif primitive_type == "disc": obj = c4d.BaseObject(c4d.Odisc) # Use ORAD/IRAD for disc obj[c4d.PRIM_DISC_ORAD] = size[0] / 2.0 obj[c4d.PRIM_DISC_IRAD] = 0 # Default inner radius elif primitive_type == "tube": obj = c4d.BaseObject(c4d.Otube) obj[c4d.PRIM_TUBE_RADIUS] = size[0] / 2.0 obj[c4d.PRIM_TUBE_IRADIUS] = size[1] / 2.0 obj[c4d.PRIM_TUBE_HEIGHT] = size[2] elif primitive_type == "torus": obj = c4d.BaseObject(c4d.Otorus) # Use RINGRAD/PIPERAD for Torus obj[c4d.PRIM_TORUS_RINGRAD] = size[0] / 2.0 obj[c4d.PRIM_TORUS_PIPERAD] = size[1] / 2.0 elif primitive_type == "platonic": obj = c4d.BaseObject(c4d.Oplatonic) obj[c4d.PRIM_PLATONIC_TYPE] = c4d.PRIM_PLATONIC_TYPE_TETRA obj[c4d.PRIM_PLATONIC_RAD] = size[0] / 2.0 else: self.log( f"Unknown primitive_type: {primitive_type}, defaulting to cube." ) obj = c4d.BaseObject(c4d.Ocube) obj[c4d.PRIM_CUBE_LEN] = c4d.Vector(*size) if obj is None: # Check if object creation failed return { "error": f"Failed to create base object for type '{primitive_type}'" } # Set common properties obj.SetName(requested_name) obj.SetAbsPos(c4d.Vector(*position)) # Add to doc and finalize doc.InsertObject(obj) doc.AddUndo(c4d.UNDOTYPE_NEW, obj) # Add Undo step doc.SetActiveObject(obj) # Make it active c4d.EventAdd() # --- MODIFIED FOR CONTEXT --- actual_name = obj.GetName() guid = str(obj.GetGUID()) pos_vec = obj.GetAbsPos() obj_type_name = self.get_object_type_name(obj) # Register the object self.register_object_name(obj, requested_name) # Return contextual information return { "object": { "requested_name": requested_name, "actual_name": actual_name, "guid": guid, "type": obj_type_name, "type_id": obj.GetType(), "position": [pos_vec.x, pos_vec.y, pos_vec.z], } } # --- END MODIFIED --- except Exception as e: # Catch errors during object creation or property setting self.log( f"[**ERROR**] Error adding primitive '{requested_name}': {str(e)}\n{traceback.format_exc()}" ) # Clean up object if created but not inserted if obj and not obj.GetDocument(): try: obj.Remove() except: pass return { "error": f"Failed to add primitive: {str(e)}", "traceback": traceback.format_exc(), } def register_object_name(self, obj, requested_name): """Register object GUID, actual name, and requested name for context tracking.""" if not obj or not isinstance(obj, c4d.BaseObject): self.log("[C4D REG] Invalid object provided for registration.") return # Ensure registries exist (redundant if __init__ is correct, but safe) if not hasattr(self, "_name_to_guid_registry"): self._name_to_guid_registry = {} if not hasattr(self, "_guid_to_name_registry"): self._guid_to_name_registry = {} # Keep original for compatibility if needed if not hasattr(self, "_object_name_registry"): self._object_name_registry = {} try: # Ensure the object is part of a document before getting GUID if not obj.GetDocument(): self.log( f"[C4D REG] ## Warning ##: Object '{obj.GetName()}' not in document, cannot get reliable GUID." ) return # Skip registration if not in doc obj_id = str(obj.GetGUID()) actual_name = obj.GetName() if not obj_id or len(obj_id) < 10: # Basic check for non-empty GUID self.log( f"[C4D REG] ## Warning ##: Got potentially invalid GUID '{obj_id}' for object '{actual_name}'. Cannot register." ) return if not requested_name: self.log( f"[C4D REG] ## Warning ## Empty requested name provided for '{actual_name}', using actual name." ) requested_name = actual_name # Prepare names for registry check (lower case) req_name_lower = requested_name.lower() act_name_lower = actual_name.lower() # Clean up potentially stale entries for these names in _name_to_guid_registry for name_lower in {req_name_lower, act_name_lower}: old_guid = self._name_to_guid_registry.pop(name_lower, None) if old_guid and old_guid != obj_id: self.log( f"[C4D REG] Cleaning old name->guid mapping: '{name_lower}' pointed to {old_guid}, now points to {obj_id}" ) # Also remove the reverse mapping for the old GUID if it exists old_reg_entry = self._guid_to_name_registry.get(old_guid) if old_reg_entry: old_req_lower_check = old_reg_entry.get( "requested_name", "" ).lower() old_act_lower_check = old_reg_entry.get( "actual_name", "" ).lower() if ( old_req_lower_check == name_lower or old_act_lower_check == name_lower ): self._guid_to_name_registry.pop(old_guid, None) self.log( f"[C4D REG] Removed stale guid->name entry for {old_guid}" ) # Clean up potentially stale entry for this GUID in _guid_to_name_registry old_name_entry = self._guid_to_name_registry.pop(obj_id, None) if old_name_entry: # Remove old name mappings associated with this GUID from _name_to_guid_registry self._name_to_guid_registry.pop( old_name_entry.get("requested_name", "").lower(), None ) self._name_to_guid_registry.pop( old_name_entry.get("actual_name", "").lower(), None ) self.log( f"[C4D REG] Cleaning old guid->name mapping for {obj_id} (was pointing to '{old_name_entry.get('actual_name')}')." ) # Add the new mappings to the *new* registries self._name_to_guid_registry[req_name_lower] = obj_id if ( act_name_lower != req_name_lower ): # Avoid duplicate key if names are same self._name_to_guid_registry[act_name_lower] = obj_id self._guid_to_name_registry[obj_id] = { "requested_name": requested_name, "actual_name": actual_name, } # --- Keep Original Registry Logic (Optional - for strict backward compatibility) --- # If you need the old registry structure for some reason, keep these lines. # Otherwise, they can be removed once find_object_by_name is fully updated. self._object_name_registry[obj_id] = requested_name self._object_name_registry[requested_name] = obj_id # --- End Optional Original Registry --- self.log( f"[C4D REG] Registered: Req='{requested_name}', Act='{actual_name}', GUID={obj_id}" ) # User Data part from original (keep as is) try: has_tag = False userdata = obj.GetUserDataContainer() if userdata: for data_index in range(len(userdata)): desc_entry = userdata[data_index] # Check if it's a valid description element before accessing DESC_NAME if ( isinstance(desc_entry, tuple) and len(desc_entry) > c4d.DESC_NAME ): # Handle tuple structure if desc_entry[c4d.DESC_NAME] == "mcp_original_name": has_tag = True break elif hasattr(desc_entry, "__getitem__") and c4d.DESC_NAME < len( desc_entry ): # Handle potential sequence access if desc_entry[c4d.DESC_NAME] == "mcp_original_name": has_tag = True break if not has_tag: bc = c4d.GetCustomDataTypeDefault(c4d.DTYPE_STRING) if bc: bc[c4d.DESC_NAME] = "mcp_original_name" bc[c4d.DESC_SHORT_NAME] = "MCP Name" element = obj.AddUserData(bc) if element: # Make sure element is a DescID before using it as an index if isinstance(element, c4d.DescID): obj[element] = requested_name self.log( f"[C4D] Stored original name '{requested_name}' in object user data" ) else: # Handle case where AddUserData returns index directly (older C4D?) try: descid_from_index = obj.GetUserDataContainer()[ element ][c4d.DESC_ID] obj[descid_from_index] = requested_name self.log( f"[C4D] Stored original name '{requested_name}' in object user data (via index)" ) except Exception as e_ud_index: self.log( f"[C4D] ## Warning ##: AddUserData returned unexpected value '{element}', cannot set user data: {e_ud_index}" ) except Exception as e: # Catch potential errors during GetUserDataContainer or AddUserData self.log( f"[C4D] ## Warning ##: Could not add/check user data for original name: {str(e)}\n{traceback.format_exc()}" ) except Exception as e: # Catch potential errors during GetGUID, GetName etc. failed_name = requested_name or (obj.GetName() if obj else "UnknownObject") self.log( f"[**ERROR**] Failed to register object '{failed_name}': {e}\n{traceback.format_exc()}" ) def handle_render_preview_base64(self, frame=0, width=640, height=360): """SDK 2025-compliant base64 renderer with error resolution""" import c4d import base64 import traceback def _execute_render(): try: doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # 1. Camera Validation (Critical Fix) if not doc.GetActiveBaseDraw().GetSceneCamera(doc): return {"error": "No active camera (create camera first)"} # 2. RenderData Protocol Fix (SDK §9.1.3) original_rd = doc.GetActiveRenderData() if not original_rd: return {"error": "No render settings configured"} rd_clone = original_rd.GetClone(c4d.COPYFLAGS_NONE) if not rd_clone: return {"error": "RenderData clone failed"} try: doc.InsertRenderData(rd_clone) doc.SetActiveRenderData(rd_clone) # Required activation # 3. 2025-Specific Configuration settings = rd_clone.GetData() settings[c4d.RDATA_XRES] = width settings[c4d.RDATA_YRES] = height settings[c4d.RDATA_FRAMESEQUENCE] = ( c4d.RDATA_FRAMESEQUENCE_CURRENTFRAME ) # 4. Mandatory Flags (SDK §9.4.5) render_flags = ( c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS | 0x00040000 # EMBREE_STREAMING | c4d.RENDERFLAGS_NODOCUMENTCLONE ) # 5. Bitmap Initialization (SDK §11.2.3) bmp = c4d.bitmaps.MultipassBitmap(width, height, c4d.COLORMODE_RGB) bmp.AddChannel(True, True) # Required alpha # 6. Frame Synchronization doc.SetTime(c4d.BaseTime(frame, doc.GetFps())) doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE) # 7. Core Render Execution result = c4d.documents.RenderDocument( doc, settings, bmp, render_flags ) if result != c4d.RENDERRESULT_OK: return { "error": f"Render failed: {self._render_code_to_str(result)}" } # 8. MemoryFile Handling Fix mem_file = c4d.storage.MemoryFileStruct() mem_file.SetMemoryWriteMode() if bmp.Save(mem_file, c4d.FILTER_PNG) != c4d.IMAGERESULT_OK: return {"error": "PNG encoding failed"} data, _ = mem_file.GetData() return { "success": True, "image_base64": f"data:image/png;base64,{base64.b64encode(data).decode()}", } finally: # 9. Correct Resource Cleanup (SDK §9.1.4) if rd_clone: rd_clone.Remove() # Fixed removal method if "bmp" in locals(): bmp.FlushAll() c4d.EventAdd() except Exception as e: return {"error": f"Render failure: {str(e)}"} return self.execute_on_main_thread(_execute_render, _timeout=120) def _render_code_to_str(self, code): """Convert Cinema4D render result codes to human-readable strings""" codes = { 0: "Success", 1: "Out of memory", 2: "Command canceled", 3: "Missing assets", 4: "Rendering in progress", 5: "Invalid document", 6: "Version mismatch", 7: "Network error", 8: "Invalid parameters", 9: "IO error", } return codes.get(code, f"Unknown error ({code})") def handle_modify_object(self, command): """Handle modify_object command with full property support, GUID option, and Camera params.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} properties = command.get("properties", {}) if not properties: return {"error": "No properties provided to modify."} # --- Identifier Detection --- identifier = None use_guid = False if command.get("guid"): identifier = command.get("guid") use_guid = True self.log(f"[MODIFY] Using GUID identifier: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") identifier_str = str(identifier) if "-" in identifier_str and len(identifier_str) > 30: use_guid = True self.log(f"[MODIFY] Identifier '{identifier}' looks like GUID.") else: use_guid = False self.log(f"[MODIFY] Using Name identifier: '{identifier}'") elif command.get("name"): identifier = command.get("name") use_guid = False self.log(f"[MODIFY] Using 'name' key as Name identifier: '{identifier}'") else: return { "error": "No object identifier ('guid', 'object_name', or 'name') provided." } # Find the object using the determined identifier and flag obj = self.find_object_by_name(doc, identifier, use_guid=use_guid) if obj is None: search_type = "GUID" if use_guid else "Name" return { "error": f"Object '{identifier}' (searched by {search_type}) not found." } # Apply modifications modified = {} name_before = obj.GetName() something_changed = False obj_type = obj.GetType() # Get type for specific param handling try: doc.StartUndo() # Start undo block # Position pos_val = properties.get("position") if isinstance(pos_val, list) and len(pos_val) >= 3: try: new_pos = c4d.Vector( float(pos_val[0]), float(pos_val[1]), float(pos_val[2]) ) if obj.GetAbsPos() != new_pos: obj.SetAbsPos(new_pos) modified["position"] = [new_pos.x, new_pos.y, new_pos.z] something_changed = True except (ValueError, TypeError) as e: self.log(f"Warning: Invalid position value '{pos_val}': {e}") # Rotation rot_val = properties.get("rotation") if isinstance(rot_val, list) and len(rot_val) >= 3: try: new_rot_deg = [float(r) for r in rot_val[:3]] new_rot_rad = c4d.Vector( *[c4d.utils.DegToRad(r) for r in new_rot_deg] ) obj.SetAbsRot(new_rot_rad) modified["rotation"] = new_rot_deg something_changed = True except (ValueError, TypeError) as e: self.log(f"Warning: Invalid rotation value '{rot_val}': {e}") # Scale scale_val = properties.get("scale") if isinstance(scale_val, list) and len(scale_val) >= 3: try: new_scale = c4d.Vector( float(scale_val[0]), float(scale_val[1]), float(scale_val[2]) ) if obj.GetAbsScale() != new_scale: obj.SetAbsScale(new_scale) modified["scale"] = [new_scale.x, new_scale.y, new_scale.z] something_changed = True except (ValueError, TypeError) as e: self.log(f"Warning: Invalid scale value '{scale_val}': {e}") # Color color_val = properties.get("color") if isinstance(color_val, list) and len(color_val) >= 3: try: new_color = c4d.Vector( max(0.0, min(1.0, float(color_val[0]))), max(0.0, min(1.0, float(color_val[1]))), max(0.0, min(1.0, float(color_val[2]))), ) if ( obj.IsCorrectType(c4d.Opoint) or obj.IsCorrectType(c4d.Opolygon) or obj.IsCorrectType(c4d.Ospline) or obj.IsCorrectType(c4d.Onull) ): if ( obj.GetParameter(c4d.DescID(c4d.ID_BASEOBJECT_COLOR))[1] != new_color ): # Safer comparison obj[c4d.ID_BASEOBJECT_USECOLOR] = ( c4d.ID_BASEOBJECT_USECOLOR_ON ) obj[c4d.ID_BASEOBJECT_COLOR] = new_color modified["color"] = [new_color.x, new_color.y, new_color.z] something_changed = True else: self.log( f"Warning: Cannot set display color for object type {obj.GetType()} ('{name_before}')" ) except (ValueError, TypeError, AttributeError) as e: self.log(f"Warning: Error setting color for '{name_before}': {e}") # Primitive Size size = properties.get("size") if isinstance(size, list) and len(size) > 0: obj_type = obj.GetType() size_applied = False new_size_applied = [] try: safe_size = [float(s) for s in size if s is not None] if not safe_size: raise ValueError("No valid numeric sizes") sx, sy, sz = ( safe_size[0], safe_size[1] if len(safe_size) > 1 else safe_size[0], safe_size[2] if len(safe_size) > 2 else safe_size[0], ) if obj_type == c4d.Ocube: new_val = c4d.Vector(sx, sy, sz) current = obj[c4d.PRIM_CUBE_LEN] setter = lambda v: obj.SetParameter( c4d.DescID(c4d.PRIM_CUBE_LEN), v, c4d.DESCFLAGS_SET_NONE ) params = [sx, sy, sz] elif obj_type == c4d.Osphere: new_val = sx / 2.0 current = obj[c4d.PRIM_SPHERE_RAD] setter = lambda v: obj.SetParameter( c4d.DescID(c4d.PRIM_SPHERE_RAD), v, c4d.DESCFLAGS_SET_NONE ) params = [sx] elif obj_type == c4d.Ocone: new_val = (sx / 2.0, sy) current = (obj[c4d.PRIM_CONE_BRAD], obj[c4d.PRIM_CONE_HEIGHT]) setter = lambda v: obj.SetParameters( { c4d.DescID(c4d.PRIM_CONE_BRAD): v[0], c4d.DescID(c4d.PRIM_CONE_HEIGHT): v[1], } ) params = [sx, sy] elif obj_type == c4d.Ocylinder: new_val = (sx / 2.0, sy) current = ( obj[c4d.PRIM_CYLINDER_RADIUS], obj[c4d.PRIM_CYLINDER_HEIGHT], ) setter = lambda v: obj.SetParameters( { c4d.DescID(c4d.PRIM_CYLINDER_RADIUS): v[0], c4d.DescID(c4d.PRIM_CYLINDER_HEIGHT): v[1], } ) params = [sx, sy] elif obj_type == c4d.Oplane: new_val = (sx, sy) current = ( obj[c4d.PRIM_PLANE_WIDTH], obj[c4d.PRIM_PLANE_HEIGHT], ) setter = lambda v: obj.SetParameters( { c4d.DescID(c4d.PRIM_PLANE_WIDTH): v[0], c4d.DescID(c4d.PRIM_PLANE_HEIGHT): v[1], } ) params = [sx, sy] # Add other primitives here if needed... else: new_val = None current = None setter = None params = None # Indicate not applicable if setter and new_val is not None and current != new_val: setter(new_val) size_applied = True new_size_applied = params if size_applied: modified["size"] = new_size_applied something_changed = True elif size: self.log( f"Info: 'size' prop not applicable to type {obj_type} ('{name_before}')" ) except Exception as e_size: self.log( f"Warning: Error modifying size for {name_before}: {e_size}" ) # --- NEW: Camera Specific Properties --- elif obj_type == c4d.Ocamera: bc = obj.GetDataInstance() if bc: focal_length = properties.get("focal_length") if focal_length is not None: try: val = float(focal_length) focus_id = getattr( c4d, "CAMERAOBJECT_FOCUS", c4d.CAMERA_FOCUS ) if bc[focus_id] != val: bc[focus_id] = val modified["focal_length"] = val something_changed = True except (ValueError, TypeError, AttributeError) as e: self.log( f"Warning: Failed to set focal_length '{focal_length}': {e}" ) focus_distance = properties.get("focus_distance") if focus_distance is not None: try: val = float(focus_distance) dist_id = getattr( c4d, "CAMERAOBJECT_TARGETDISTANCE", None ) # ID for focus distance if dist_id and bc[dist_id] != val: bc[dist_id] = val modified["focus_distance"] = val something_changed = True elif not dist_id: self.log( "Warning: CAMERAOBJECT_TARGETDISTANCE parameter not found." ) except (ValueError, TypeError, AttributeError) as e: self.log( f"Warning: Failed to set focus_distance '{focus_distance}': {e}" ) else: self.log( f"Warning: Could not get BaseContainer for camera '{name_before}'" ) # Rename - process *after* other properties in case identifier was 'name' requested_new_name = properties.get("name") if isinstance(requested_new_name, str): new_name_stripped = requested_new_name.strip() if new_name_stripped and new_name_stripped != name_before: self.log( f"[MODIFY] Renaming '{name_before}' to '{new_name_stripped}'" ) obj.SetName(new_name_stripped) name_after_rename = obj.GetName() modified["name"] = { "from": name_before, "requested": new_name_stripped, "to": name_after_rename, } something_changed = True self.register_object_name( obj, new_name_stripped ) # Register with requested new name # Finalize if something_changed: doc.AddUndo(c4d.UNDOTYPE_CHANGE, obj) c4d.EventAdd() else: self.log(f"No modifications applied to '{name_before}'") doc.EndUndo() # End undo block # Contextual Return final_name = obj.GetName() guid = str(obj.GetGUID()) pos_vec = obj.GetAbsPos() rot_vec_rad = obj.GetAbsRot() scale_vec = obj.GetAbsScale() if "name" not in modified and final_name != name_before: self.log( f"Warning: Object name changed unexpectedly from '{name_before}' to '{final_name}'. Updating registry." ) self.register_object_name(obj, name_before) return { "object": { "requested_identifier": identifier, "was_guid": use_guid, "actual_name": final_name, "guid": guid, "name_before": name_before, "modified_properties": modified, "current_position": [pos_vec.x, pos_vec.y, pos_vec.z], "current_rotation": [ c4d.utils.RadToDeg(r) for r in [rot_vec_rad.x, rot_vec_rad.y, rot_vec_rad.z] ], "current_scale": [scale_vec.x, scale_vec.y, scale_vec.z], } } except Exception as e: if doc and doc.IsUndoEnabled(): doc.EndUndo() # Ensure undo ended error_msg = f"Unexpected error modifying object '{name_before}': {str(e)}" self.log(f"[**ERROR**] {error_msg}\n{traceback.format_exc()}") return {"error": error_msg, "traceback": traceback.format_exc()} def handle_apply_material(self, command): """Handle apply_material command with GUID support.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} material_name = command.get("material_name", "") identifier = None use_guid = False # --- GUID Detection Improved --- if command.get("guid"): identifier = command.get("guid") use_guid = True self.log(f"[APPLY MAT] Using GUID identifier: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") if "-" in str(identifier) and len(str(identifier)) > 30: self.log( f"[APPLY MAT] Identifier '{identifier}' looks like GUID, treating as GUID." ) use_guid = True else: use_guid = False self.log(f"[APPLY MAT] Using Name identifier: '{identifier}'") else: return {"error": "No object identifier ('guid' or 'object_name') provided."} # Find object obj = self.find_object_by_name(doc, identifier, use_guid=use_guid) if obj is None: search_type = "GUID" if use_guid else "Name" return { "error": f"Object '{identifier}' (searched by {search_type}) not found." } # Find material mat = self._find_material_by_name(doc, material_name) if mat is None: return {"error": f"Material not found: {material_name}"} material_type = command.get("material_type", "standard").lower() projection_type = command.get("projection_type", "cubic").lower() auto_uv = command.get("auto_uv", False) procedural = command.get("procedural", False) try: doc.StartUndo() # Create and configure texture tag tag = c4d.TextureTag() if not tag: raise RuntimeError("Failed to create TextureTag") tag.SetMaterial(mat) proj_map = { "cubic": c4d.TEXTURETAG_PROJECTION_CUBIC, "spherical": c4d.TEXTURETAG_PROJECTION_SPHERICAL, "flat": c4d.TEXTURETAG_PROJECTION_FLAT, "cylindrical": c4d.TEXTURETAG_PROJECTION_CYLINDRICAL, "frontal": c4d.TEXTURETAG_PROJECTION_FRONTAL, "uvw": c4d.TEXTURETAG_PROJECTION_UVW, } tag[c4d.TEXTURETAG_PROJECTION] = proj_map.get( projection_type, c4d.TEXTURETAG_PROJECTION_UVW ) obj.InsertTag(tag) doc.AddUndo(c4d.UNDOTYPE_NEW, tag) # Auto UV generation if auto_uv: self.log( f"[APPLY MAT] Attempting auto UV generation for '{obj.GetName()}'" ) try: if obj.IsInstanceOf(c4d.Opolygon): uvw_tag = obj.GetTag(c4d.Tuvw) if not uvw_tag: uvw_tag = obj.MakeTag(c4d.Tuvw) if uvw_tag: doc.AddUndo(c4d.UNDOTYPE_NEW, uvw_tag) else: self.log("Warning: Failed to create UVW tag.") if uvw_tag: c4d.plugins.CallCommand(12205) # Optimal Cubic Mapping self.log("Executed Optimal (Cubic) UV mapping command.") else: self.log( "Warning: Could not get or create UVW tag for auto UV." ) else: self.log( f"Warning: Auto UV skipped, object '{obj.GetName()}' not a polygon." ) except Exception as e_uv: self.log( f"[**ERROR**] Error during auto UV generation: {str(e_uv)}" ) # Handle Redshift if ( material_type == "redshift" and hasattr(c4d, "modules") and hasattr(c4d.modules, "redshift") ): self.log( f"[APPLY MAT] Checking Redshift setup for material '{mat.GetName()}'" ) try: redshift = c4d.modules.redshift rs_id = getattr(c4d, "ID_REDSHIFT_MATERIAL", 1036224) if mat.GetType() != rs_id: self.log( f"Converting material '{mat.GetName()}' to Redshift (ID: {rs_id})" ) rs_mat = c4d.BaseMaterial(rs_id) if not rs_mat: raise RuntimeError("Failed to create Redshift material") rs_mat.SetName(f"RS_{mat.GetName()}") doc.InsertMaterial(rs_mat) doc.AddUndo(c4d.UNDOTYPE_NEW, rs_mat) try: if hasattr(c4d, "REDSHIFT_MATERIAL_DIFFUSE_COLOR"): rs_mat[c4d.REDSHIFT_MATERIAL_DIFFUSE_COLOR] = mat[ c4d.MATERIAL_COLOR_COLOR ] except Exception as e_color_copy: self.log( f"Warning: Could not copy color during RS conversion: {e_color_copy}" ) try: import maxon ns_id = maxon.Id( "com.redshift3d.redshift4c4d.class.nodespace" ) node_rs_mat = c4d.NodeMaterial(rs_mat) if node_rs_mat and not node_rs_mat.HasSpace(ns_id): node_rs_mat.CreateDefaultGraph(ns_id) self.log("Created default Redshift node graph.") except Exception as e_graph: self.log( f"Warning: Failed to create Redshift graph: {e_graph}" ) if procedural: try: node_space = redshift.GetRSMaterialNodeSpace(rs_mat) root = redshift.GetRSMaterialRootShader(rs_mat) if node_space and root: tex_node = ( redshift.RSMaterialNodeCreator.CreateNode( node_space, redshift.RSMaterialNodeType.TEXTURE, "RS::TextureNode", ) ) if tex_node: tex_node[redshift.TEXTURE_TYPE] = ( redshift.TEXTURE_NOISE ) redshift.CreateConnectionBetweenNodes( node_space, tex_node, "outcolor", root, "diffuse_color", ) self.log( "Connected procedural Noise node to diffuse color." ) else: self.log( "Warning: Failed to create procedural texture node." ) except Exception as e_proc: self.log( f"Warning: Error setting up procedural RS nodes: {e_proc}" ) tag.SetMaterial(rs_mat) mat = rs_mat doc.AddUndo(c4d.UNDOTYPE_CHANGE, tag) self.log( f"Swapped tag to use new Redshift material '{rs_mat.GetName()}'" ) except Exception as e_rs_setup: self.log( f"[**ERROR**] Error during Redshift setup: {str(e_rs_setup)}" ) doc.EndUndo() c4d.EventAdd() return { "success": True, "message": f"Applied material '{mat.GetName()}' to object '{obj.GetName()}'.", "object_name": obj.GetName(), "object_guid": str(obj.GetGUID()), "material_name": mat.GetName(), "material_type_id": mat.GetType(), "projection": projection_type, "auto_uv_attempted": auto_uv, } except Exception as e: doc.EndUndo() err = f"Error applying material '{material_name}' to '{obj.GetName()}': {str(e)}" self.log(f"[**ERROR**] {err}\n{traceback.format_exc()}") return {"error": err, "traceback": traceback.format_exc()} # def handle_render_to_file(self, doc, frame, width, height, output_path=None): # """Render a frame to file, with optional base64 and fallback output path.""" # import os # import tempfile # import time # import base64 # import c4d.storage # import traceback # try: # start_time = time.time() # # Clone active render settings # render_data = doc.GetActiveRenderData() # if not render_data: # return {"error": "No active RenderData found"} # rd_clone = render_data.GetClone() # if not rd_clone: # return {"error": "Failed to clone render settings"} # # Update render settings # settings = rd_clone.GetData() # settings[c4d.RDATA_XRES] = float(width) # settings[c4d.RDATA_YRES] = float(height) # settings[c4d.RDATA_PATH] = output_path or os.path.join( # tempfile.gettempdir(), "temp_render_output.png" # ) # settings[c4d.RDATA_RENDERENGINE] = c4d.RDATA_RENDERENGINE_STANDARD # settings[c4d.RDATA_FRAMESEQUENCE] = c4d.RDATA_FRAMESEQUENCE_CURRENTFRAME # settings[c4d.RDATA_SAVEIMAGE] = False # # render_data.SetData(settings) # # Create temp RenderData container # # Insert actual RenderData object into the scene with settings # temp_rd = c4d.documents.RenderData() # temp_rd.SetData(settings) # doc.InsertRenderData(temp_rd) # # Update document time/frame # if isinstance(frame, dict): # frame = frame.get("frame", 0) # doc.SetTime(c4d.BaseTime(frame, doc.GetFps())) # doc.ExecutePasses(None, True, True, True, c4d.BUILDFLAGS_NONE) # # Create target bitmap # bmp = c4d.bitmaps.BaseBitmap() # if not bmp.Init(int(width), int(height)): # return {"error": "Failed to initialize bitmap"} # self.log(f"[RENDER] Rendering frame {frame} at {width}x{height}...") # self.log(f"[RENDER DEBUG] Using RenderData name: {temp_rd.GetName()}") # self.log( # f"[RENDER DEBUG] Width: {settings[c4d.RDATA_XRES]}, Height: {settings[c4d.RDATA_YRES]}" # ) # # Render to bitmap # result = c4d.documents.RenderDocument( # doc, # temp_rd.GetData(), # bmp, # c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_NODOCUMENTCLONE, # None, # ) # if not result: # self.log("[RENDER] RenderDocument returned False") # return {"error": "RenderDocument failed"} # # Fallback path if needed # if not output_path: # doc_name = doc.GetDocumentName() or "untitled" # if doc_name.lower().endswith(".c4d"): # doc_name = doc_name[:-4] # base_dir = doc.GetDocumentPath() or tempfile.gettempdir() # output_path = os.path.join(base_dir, f"{doc_name}_snapshot_{frame}.png") # # Choose format based on extension # ext = os.path.splitext(output_path)[1].lower() # format_map = { # ".png": c4d.FILTER_PNG, # ".jpg": c4d.FILTER_JPG, # ".jpeg": c4d.FILTER_JPG, # ".tif": c4d.FILTER_TIF, # ".tiff": c4d.FILTER_TIF, # } # format_id = format_map.get(ext, c4d.FILTER_PNG) # # Save image to file # if not bmp.Save(output_path, format_id): # self.log(f"[RENDER] Failed to save bitmap to file: {output_path}") # return {"error": f"Failed to save image to: {output_path}"} # # Optionally encode to base64 if PNG # image_base64 = None # if format_id == c4d.FILTER_PNG: # mem_file = c4d.storage.MemoryFileWrite() # if mem_file.Open(1024 * 1024): # if bmp.Save(mem_file, c4d.FILTER_PNG): # raw_bytes = mem_file.GetValue() # image_base64 = base64.b64encode(raw_bytes).decode("utf-8") # self.log("[RENDER] Base64 preview generated") # mem_file.Close() # elapsed = round(time.time() - start_time, 3) # return { # "success": True, # "frame": frame, # "resolution": f"{width}x{height}", # "output_path": output_path, # "file_exists": os.path.exists(output_path), # "image_base64": image_base64, # "render_time": elapsed, # } # except Exception as e: # self.log("[RENDER ] Exception during render_to_file") # self.log(traceback.format_exc()) # return {"error": f"Exception during render: {str(e)}"} def handle_snapshot_scene(self, command=None): """ Generates a snapshot: object list + base64 preview render. Uses the corrected core render logic via handle_render_preview_base64. """ doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document for snapshot."} frame = doc.GetTime().GetFrame(doc.GetFps()) width, height = 640, 360 self.log(f"[C4D SNAPSHOT] Generating snapshot for frame {frame}...") # 1. List objects object_data = self.handle_list_objects() # Runs via execute_on_main_thread objects = object_data.get("objects", []) # 2. Render preview - uses handle_render_preview_base64 which now uses corrected core logic render_command = {"width": width, "height": height, "frame": frame} render_result = self.handle_render_preview_base64( **render_command ) # Runs via execute_on_main_thread render_info = {} if render_result and render_result.get("success"): render_info = { "frame": render_result.get("frame", frame), "resolution": f"{render_result.get('width', width)}x{render_result.get('height', height)}", "image_base64": render_result.get("image_base64"), "render_time": render_result.get("render_time", 0.0), "format": render_result.get("format", "png"), "success": True, } self.log(f"[C4D SNAPSHOT] Render successful.") else: error_msg = render_result.get("error", "Unknown rendering error") render_info = {"error": error_msg, "success": False} self.log(f"[C4D SNAPSHOT] Render failed: {error_msg}") # Include traceback from render result if available if isinstance(render_result, dict) and "traceback" in render_result: render_info["traceback"] = render_result["traceback"] # 3. Return combined result return { "objects": objects, "render": render_info, } def handle_set_keyframe(self, command): """Set a keyframe on an object, supporting both GUID and name lookup.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # --- Identifier Detection --- identifier = None use_guid = False if command.get("guid"): identifier = command.get("guid") use_guid = True self.log(f"[KEYFRAME] Using GUID identifier: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") if "-" in str(identifier) and len(str(identifier)) > 30: use_guid = True self.log( f"[KEYFRAME] Identifier '{identifier}' looks like GUID, treating as GUID." ) else: use_guid = False self.log(f"[KEYFRAME] Using Name identifier: '{identifier}'") else: identifier = command.get("name") if identifier: use_guid = False self.log( f"[KEYFRAME] Using 'name' key as Name identifier: '{identifier}'" ) else: return { "error": "No object identifier ('guid', 'object_name', or 'name') provided." } # Find object obj = self.find_object_by_name(doc, identifier, use_guid=use_guid) if obj is None: search_type = "GUID" if use_guid else "Name" return { "error": f"Object '{identifier}' (searched by {search_type}) not found for keyframing." } # --- Property, Frame, and Value --- property_type = ( command.get("property_type") or command.get("property") or "position" ).lower() frame = command.get("frame", doc.GetTime().GetFrame(doc.GetFps())) value = command.get("value") if value is None: return {"error": "No 'value' provided for keyframe."} try: frame = int(frame) except (ValueError, TypeError): return {"error": f"Invalid frame: {frame}"} try: # --- Handle Different Property Types --- if "." in property_type: # Vector component property (e.g., position.x) parts = property_type.split(".") if len(parts) != 2: return { "error": f"Invalid property format: '{property_type}'. Use 'position.x' etc." } base_property, component = parts property_map = { "position": c4d.ID_BASEOBJECT_POSITION, "rotation": c4d.ID_BASEOBJECT_ROTATION, "scale": c4d.ID_BASEOBJECT_SCALE, "color": c4d.LIGHT_COLOR if obj.GetType() == c4d.Olight else None, } component_map = { "x": c4d.VECTOR_X, "y": c4d.VECTOR_Y, "z": c4d.VECTOR_Z, "r": c4d.VECTOR_X, "g": c4d.VECTOR_Y, "b": c4d.VECTOR_Z, } if ( base_property not in property_map or property_map[base_property] is None ): return { "error": f"Unsupported/invalid base property '{base_property}' for object type." } if component not in component_map: return { "error": f"Unsupported component '{component}'. Use x, y, z, r, g, or b." } if isinstance(value, list): value = value[0] if value else 0.0 result = self._set_vector_component_keyframe( obj, frame, property_map[base_property], component_map[component], float(value), base_property, component, ) if not result: return {"error": f"Failed to set {property_type} keyframe"} elif property_type in ["position", "rotation", "scale"]: # Full vector properties property_ids = { "position": c4d.ID_BASEOBJECT_POSITION, "rotation": c4d.ID_BASEOBJECT_ROTATION, "scale": c4d.ID_BASEOBJECT_SCALE, } if isinstance(value, (int, float)): value = [float(value)] * 3 elif isinstance(value, list): if len(value) == 1: value = [float(value[0])] * 3 elif len(value) == 2: value = [float(value[0]), float(value[1]), 0.0] elif len(value) > 3: value = [float(v) for v in value[:3]] else: value = [float(v) for v in value] else: return { "error": f"{property_type.capitalize()} value must be a number or a list [x,y,z]." } if len(value) != 3: return { "error": f"{property_type.capitalize()} value must have 3 components." } result = self._set_vector_keyframe( obj, frame, property_ids[property_type], value, property_type ) if not result: return {"error": f"Failed to set {property_type} keyframe"} elif obj.GetType() == c4d.Olight and property_type in [ "intensity", "color", ]: if property_type == "intensity": if isinstance(value, list): value = value[0] if value else 0.0 result = self._set_scalar_keyframe( obj, frame, c4d.LIGHT_BRIGHTNESS, c4d.DTYPE_REAL, float(value) / 100.0, "intensity", ) if not result: return {"error": "Failed to set intensity keyframe"} elif property_type == "color": if not isinstance(value, list) or len(value) < 3: return {"error": "Color must be a list [r,g,b]."} result = self._set_vector_keyframe( obj, frame, c4d.LIGHT_COLOR, value[:3], "color" ) if not result: return {"error": "Failed to set color keyframe"} else: return { "error": f"Unsupported property type '{property_type}' for object '{obj.GetName()}'." } # --- Success --- return { "keyframe_set": { "object_name": obj.GetName(), "object_guid": str(obj.GetGUID()), "property": property_type, "value_set": value, "frame": frame, "success": True, } } except Exception as e: self.log( f"[**ERROR**] Error setting keyframe: {str(e)}\n{traceback.format_exc()}" ) return { "error": f"Error setting keyframe: {str(e)}", "traceback": traceback.format_exc(), } def _set_position_keyframe(self, obj, frame, position): """Set a position keyframe for an object at a specific frame. Args: obj: The Cinema 4D object to keyframe frame: The frame number position: A list of [x, y, z] coordinates Returns: True if successful, False otherwise """ if not obj or not isinstance(position, list) or len(position) < 3: self.log(f"[C4D KEYFRAME] Invalid object or position for keyframe") return False try: # Get the active document and time doc = c4d.documents.GetActiveDocument() # Log what we're doing self.log( f"[C4D KEYFRAME] Setting position keyframe for {obj.GetName()} at frame {frame} to {position}" ) # Create the position vector from the list pos = c4d.Vector(position[0], position[1], position[2]) # Set the object's position obj.SetAbsPos(pos) # Create track or get existing track for position track_x = obj.FindCTrack( c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_X, c4d.DTYPE_REAL, 0), ) ) if track_x is None: track_x = c4d.CTrack( obj, c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_X, c4d.DTYPE_REAL, 0), ), ) obj.InsertTrackSorted(track_x) track_y = obj.FindCTrack( c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_Y, c4d.DTYPE_REAL, 0), ) ) if track_y is None: track_y = c4d.CTrack( obj, c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_Y, c4d.DTYPE_REAL, 0), ), ) obj.InsertTrackSorted(track_y) track_z = obj.FindCTrack( c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_Z, c4d.DTYPE_REAL, 0), ) ) if track_z is None: track_z = c4d.CTrack( obj, c4d.DescID( c4d.DescLevel(c4d.ID_BASEOBJECT_POSITION, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(c4d.VECTOR_Z, c4d.DTYPE_REAL, 0), ), ) obj.InsertTrackSorted(track_z) # Create time object for the keyframe time = c4d.BaseTime(frame, doc.GetFps()) # Set the keyframes for each axis curve_x = track_x.GetCurve() key_x = curve_x.AddKey(time) if key_x is not None and key_x["key"] is not None: key_x["key"].SetValue(curve_x, position[0]) curve_y = track_y.GetCurve() key_y = curve_y.AddKey(time) if key_y is not None and key_y["key"] is not None: key_y["key"].SetValue(curve_y, position[1]) curve_z = track_z.GetCurve() key_z = curve_z.AddKey(time) if key_z is not None and key_z["key"] is not None: key_z["key"].SetValue(curve_z, position[2]) # Update the document c4d.EventAdd() self.log( f"[C4D KEYFRAME] Successfully set keyframe for {obj.GetName()} at frame {frame}" ) return True except Exception as e: self.log(f"[C4D KEYFRAME] Error setting position keyframe: {str(e)}") return False def _set_vector_keyframe(self, obj, frame, property_id, value, property_name): """Set a keyframe for a vector property of an object. Args: obj: The Cinema 4D object to keyframe frame: The frame number property_id: The ID of the property (e.g., c4d.ID_BASEOBJECT_POSITION) value: A list of [x, y, z] values property_name: Name of the property for logging Returns: True if successful, False otherwise """ if not obj or not isinstance(value, list) or len(value) < 3: self.log( f"[C4D KEYFRAME] Invalid object or {property_name} value for keyframe" ) return False try: # Get the active document and time doc = c4d.documents.GetActiveDocument() # Log what we're doing self.log( f"[C4D KEYFRAME] Setting {property_name} keyframe for {obj.GetName()} at frame {frame} to {value}" ) # Create the vector from the list vec = c4d.Vector(value[0], value[1], value[2]) # Set the object's property value based on property type if property_id == c4d.ID_BASEOBJECT_POSITION: obj.SetAbsPos(vec) elif property_id == c4d.ID_BASEOBJECT_ROTATION: # Convert degrees to radians for rotation rot_rad = c4d.Vector( c4d.utils.DegToRad(value[0]), c4d.utils.DegToRad(value[1]), c4d.utils.DegToRad(value[2]), ) obj.SetRotation(rot_rad) elif property_id == c4d.ID_BASEOBJECT_SCALE: obj.SetScale(vec) elif property_id == c4d.LIGHT_COLOR: obj[c4d.LIGHT_COLOR] = vec # Component IDs for vector properties component_ids = [c4d.VECTOR_X, c4d.VECTOR_Y, c4d.VECTOR_Z] component_names = ["X", "Y", "Z"] # Create tracks and set keyframes for each component for i, component_id in enumerate(component_ids): # Create or get track for this component track = obj.FindCTrack( c4d.DescID( c4d.DescLevel(property_id, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(component_id, c4d.DTYPE_REAL, 0), ) ) if track is None: track = c4d.CTrack( obj, c4d.DescID( c4d.DescLevel(property_id, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(component_id, c4d.DTYPE_REAL, 0), ), ) obj.InsertTrackSorted(track) # Create time object for the keyframe time = c4d.BaseTime(frame, doc.GetFps()) # Set the keyframe curve = track.GetCurve() key = curve.AddKey(time) # Convert rotation values from degrees to radians if necessary component_value = value[i] if property_id == c4d.ID_BASEOBJECT_ROTATION: component_value = c4d.utils.DegToRad(component_value) if key is not None and key["key"] is not None: key["key"].SetValue(curve, component_value) self.log( f"[C4D KEYFRAME] Set {property_name}.{component_names[i]} keyframe to {value[i]}" ) # Update the document c4d.EventAdd() self.log( f"[C4D KEYFRAME] Successfully set {property_name} keyframe for {obj.GetName()} at frame {frame}" ) return True except Exception as e: self.log(f"[C4D KEYFRAME] Error setting {property_name} keyframe: {str(e)}") return False def _set_scalar_keyframe( self, obj, frame, property_id, data_type, value, property_name ): """Set a keyframe for a scalar property of an object. Args: obj: The Cinema 4D object to keyframe frame: The frame number property_id: The ID of the property (e.g., c4d.LIGHT_BRIGHTNESS) data_type: The data type of the property (e.g., c4d.DTYPE_REAL) value: The scalar value property_name: Name of the property for logging Returns: True if successful, False otherwise """ if not obj: self.log(f"[C4D KEYFRAME] Invalid object for {property_name} keyframe") return False try: # Get the active document and time doc = c4d.documents.GetActiveDocument() # Log what we're doing self.log( f"[C4D KEYFRAME] Setting {property_name} keyframe for {obj.GetName()} at frame {frame} to {value}" ) # Set the object's property value obj[property_id] = value # Create or get track for this property track = obj.FindCTrack(c4d.DescID(c4d.DescLevel(property_id, data_type, 0))) if track is None: track = c4d.CTrack( obj, c4d.DescID(c4d.DescLevel(property_id, data_type, 0)) ) obj.InsertTrackSorted(track) # Create time object for the keyframe time = c4d.BaseTime(frame, doc.GetFps()) # Set the keyframe curve = track.GetCurve() key = curve.AddKey(time) if key is not None and key["key"] is not None: key["key"].SetValue(curve, value) # Update the document c4d.EventAdd() self.log( f"[C4D KEYFRAME] Successfully set {property_name} keyframe for {obj.GetName()} at frame {frame}" ) return True except Exception as e: self.log(f"[C4D KEYFRAME] Error setting {property_name} keyframe: {str(e)}") return False def _set_vector_component_keyframe( self, obj, frame, property_id, component_id, value, property_name, component_name, ): """Set a keyframe for a single component of a vector property. Args: obj: The Cinema 4D object to keyframe frame: The frame number property_id: The ID of the property (e.g., c4d.ID_BASEOBJECT_POSITION) component_id: The ID of the component (e.g., c4d.VECTOR_X) value: The scalar value for the component property_name: Name of the property for logging component_name: Name of the component for logging Returns: True if successful, False otherwise """ if not obj: self.log( f"[C4D KEYFRAME] Invalid object for {property_name}.{component_name} keyframe" ) return False try: # Get the active document and time doc = c4d.documents.GetActiveDocument() # Log what we're doing self.log( f"[C4D KEYFRAME] Setting {property_name}.{component_name} keyframe for {obj.GetName()} at frame {frame} to {value}" ) # Get the current vector value current_vec = None if property_id == c4d.ID_BASEOBJECT_POSITION: current_vec = obj.GetAbsPos() elif property_id == c4d.ID_BASEOBJECT_ROTATION: current_vec = obj.GetRotation() # For rotation, convert the input value from degrees to radians value = c4d.utils.DegToRad(value) elif property_id == c4d.ID_BASEOBJECT_SCALE: current_vec = obj.GetScale() elif property_id == c4d.LIGHT_COLOR: current_vec = obj[c4d.LIGHT_COLOR] if current_vec is None: self.log(f"[C4D KEYFRAME] Could not get current {property_name} value") return False # Update the specific component if component_id == c4d.VECTOR_X: current_vec.x = value elif component_id == c4d.VECTOR_Y: current_vec.y = value elif component_id == c4d.VECTOR_Z: current_vec.z = value # Set the updated vector back to the object if property_id == c4d.ID_BASEOBJECT_POSITION: obj.SetAbsPos(current_vec) elif property_id == c4d.ID_BASEOBJECT_ROTATION: obj.SetRotation(current_vec) elif property_id == c4d.ID_BASEOBJECT_SCALE: obj.SetScale(current_vec) elif property_id == c4d.LIGHT_COLOR: obj[c4d.LIGHT_COLOR] = current_vec # Create or get track for this component track = obj.FindCTrack( c4d.DescID( c4d.DescLevel(property_id, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(component_id, c4d.DTYPE_REAL, 0), ) ) if track is None: track = c4d.CTrack( obj, c4d.DescID( c4d.DescLevel(property_id, c4d.DTYPE_VECTOR, 0), c4d.DescLevel(component_id, c4d.DTYPE_REAL, 0), ), ) obj.InsertTrackSorted(track) # Create time object for the keyframe time = c4d.BaseTime(frame, doc.GetFps()) # Set the keyframe curve = track.GetCurve() key = curve.AddKey(time) if key is not None and key["key"] is not None: key["key"].SetValue(curve, value) # Update the document c4d.EventAdd() self.log( f"[C4D KEYFRAME] Successfully set {property_name}.{component_name} keyframe for {obj.GetName()} at frame {frame}" ) return True except Exception as e: self.log( f"[C4D KEYFRAME] Error setting {property_name}.{component_name} keyframe: {str(e)}" ) return False def handle_save_scene(self, command): """Handle save_scene command.""" file_path = command.get("file_path", "") if not file_path: return {"error": "No file path provided"} # Log the save request self.log(f"[C4D SAVE] Saving scene to: {file_path}") # Define function to execute on main thread def save_scene_on_main_thread(doc, file_path): try: # Ensure the directory exists directory = os.path.dirname(file_path) if directory and not os.path.exists(directory): os.makedirs(directory) # Check file extension _, extension = os.path.splitext(file_path) if not extension: file_path += ".c4d" # Add default extension elif extension.lower() != ".c4d": file_path = file_path[: -len(extension)] + ".c4d" # Save document self.log(f"[C4D SAVE] Saving to: {file_path}") if not c4d.documents.SaveDocument( doc, file_path, c4d.SAVEDOCUMENTFLAGS_DONTADDTORECENTLIST, c4d.FORMAT_C4DEXPORT, ): return {"error": f"Failed to save document to {file_path}"} # R2025.1 fix: Update document name and path to fix "Untitled-1" issue try: # Update the document name doc.SetDocumentName(os.path.basename(file_path)) # Update document path doc.SetDocumentPath(os.path.dirname(file_path)) # Ensure UI is updated c4d.EventAdd() self.log( f"[C4D SAVE] Updated document name and path for {file_path}" ) except Exception as e: self.log( f"[C4D SAVE] ## Warning ##: Could not update document name/path: {str(e)}" ) return { "success": True, "file_path": file_path, "message": f"Scene saved to {file_path}", } except Exception as e: return {"error": f"Error saving scene: {str(e)}"} # Execute the save function on the main thread with extended timeout doc = c4d.documents.GetActiveDocument() result = self.execute_on_main_thread( save_scene_on_main_thread, args=(doc, file_path), _timeout=60 ) return result def handle_load_scene(self, command): """Handle load_scene command with improved path handling.""" file_path = command.get("file_path", "") if not file_path: return {"error": "No file path provided"} # Normalize path to handle different path formats file_path = os.path.normpath(os.path.expanduser(file_path)) # Log the normalized path self.log(f"[C4D LOAD] Normalized file path: {file_path}") # If path is not absolute, try to resolve it relative to current directory if not os.path.isabs(file_path): current_doc_path = c4d.documents.GetActiveDocument().GetDocumentPath() if current_doc_path: possible_path = os.path.join(current_doc_path, file_path) self.log( f"[C4D LOAD] Trying path relative to current document: {possible_path}" ) if os.path.exists(possible_path): file_path = possible_path # Check if file exists if not os.path.exists(file_path): # Try to find the file in common locations common_dirs = [ os.path.expanduser("~/Documents"), os.path.expanduser("~/Desktop"), "/Users/Shared/", ".", # Add the current working directory os.getcwd(), # Add the directory containing the plugin os.path.dirname(os.path.abspath(__file__)), # Add parent directory of plugin os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ] # Try with different extensions filename = os.path.basename(file_path) basename, ext = os.path.splitext(filename) if not ext: filenames_to_try = [filename, filename + ".c4d"] else: filenames_to_try = [filename] # Report search paths self.log( f"[C4D LOAD] Searching for file '{filename}' in multiple locations" ) # Try each directory and filename combination for directory in common_dirs: for fname in filenames_to_try: possible_path = os.path.join(directory, fname) self.log(f"[C4D LOAD] Trying path: {possible_path}") if os.path.exists(possible_path): file_path = possible_path self.log(f"[C4D LOAD] Found file at: {file_path}") break else: continue # Continue to next directory if file not found break # Break main loop if file found else: # Try a case-insensitive search as a last resort for directory in common_dirs: if os.path.exists(directory): for file in os.listdir(directory): if file.lower() == filename.lower(): file_path = os.path.join(directory, file) self.log( f"[C4D LOAD] Found file with case-insensitive match: {file_path}" ) break else: continue # Continue to next directory if file not found break # Break main loop if file found else: return {"error": f"File not found: {file_path}"} # Log the load request self.log(f"[C4D LOAD] Loading scene from: {file_path}") # Define function to execute on main thread def load_scene_on_main_thread(file_path): try: # Load the document new_doc = c4d.documents.LoadDocument(file_path, c4d.SCENEFILTER_NONE) if not new_doc: return {"error": f"Failed to load document from {file_path}"} # Set the new document as active c4d.documents.SetActiveDocument(new_doc) # Add the document to the documents list # (only needed if the document wasn't loaded by the document manager) c4d.documents.InsertBaseDocument(new_doc) # Update Cinema 4D c4d.EventAdd() return { "success": True, "file_path": file_path, "message": f"Scene loaded from {file_path}", } except Exception as e: return {"error": f"Error loading scene: {str(e)}"} # Execute the load function on the main thread with extended timeout result = self.execute_on_main_thread( load_scene_on_main_thread, file_path, _timeout=60 ) return result def handle_execute_python(self, command): """Handle execute_python command with improved output capturing and error handling.""" code = command.get("code", "") if not code: # Try alternative parameter names code = command.get("script", "") if not code: self.log( "[C4D PYTHON] Error: No Python code provided in 'code' or 'script' parameters" ) return {"error": "No Python code provided"} # For security, limit available modules allowed_imports = [ "c4d", "math", "random", "time", "json", "os.path", "sys", ] # Check for potentially harmful imports or functions for banned_keyword in [ "os.system", "subprocess", "exec(", "eval(", "import os", "from os import", ]: if banned_keyword in code: return { "error": f"Security: Banned keyword found in code: {banned_keyword}" } self.log(f"[C4D PYTHON] Executing Python code") # Prepare improved capture function with thread-safe collection captured_output = [] import sys import traceback from io import StringIO # Execute the code on the main thread def execute_code(): # Save original stdout original_stdout = sys.stdout # Create a StringIO object to capture output string_io = StringIO() try: # Redirect stdout to our capture object sys.stdout = string_io # Create a new namespace with limited globals sandbox = { "c4d": c4d, "math": __import__("math"), "random": __import__("random"), "time": __import__("time"), "json": __import__("json"), "doc": c4d.documents.GetActiveDocument(), } # Print startup message print("[C4D PYTHON] Starting script execution") # Execute the code exec(code, sandbox) # Print completion message print("[C4D PYTHON] Script execution completed") # Get any variables that were set in the code result_vars = { k: v for k, v in sandbox.items() if not k.startswith("__") and k not in ["c4d", "math", "random", "time", "json", "doc"] } # Get captured output full_output = string_io.getvalue() # Process variables to make them serializable processed_vars = {} for k, v in result_vars.items(): try: # Try to make the value JSON-serializable if hasattr(v, "__dict__"): processed_vars[k] = f"<{type(v).__name__} object>" else: processed_vars[k] = str(v) except: processed_vars[k] = f"<{type(v).__name__} object>" # Return results return { "success": True, "output": full_output, "variables": processed_vars, } except Exception as e: error_msg = f"Python execution error: {str(e)}" self.log(f"[C4D PYTHON] {error_msg}") # Get traceback info tb = traceback.format_exc() # Get any output captured before the error captured = string_io.getvalue() # Return error with details return { "error": error_msg, "traceback": tb, "output": captured, } finally: # Restore original stdout sys.stdout = original_stdout # Close the StringIO object string_io.close() # Execute on main thread with extended timeout result = self.execute_on_main_thread(execute_code, _timeout=30) # Check for empty output and add warning if result.get("success") and not result.get("output").strip(): self.log( "[C4D PYTHON] ## Warning ##: Script executed successfully but produced no output" ) result["warning"] = "Script executed but produced no output" return result def handle_create_mograph_cloner(self, command): """Handle create_mograph_cloner command with context and fixed parameter names.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} requested_name = command.get("cloner_name", "MoGraph Cloner") mode = command.get("mode", "grid").lower() object_identifier = command.get("object_name", None) use_child_guid = False clone_child_provided = object_identifier is not None if clone_child_provided: identifier_str = str(object_identifier) if "-" in identifier_str and len(identifier_str) > 30: use_child_guid = True elif identifier_str.isdigit() or ( identifier_str.startswith("-") and identifier_str[1:].isdigit() ): if len(identifier_str) > 10: use_child_guid = True # Count Parsing (robust version from previous step) default_count = [3, 1, 3] if mode == "grid" else 10 raw_count = command.get("count", default_count) count_vec = None count_scalar = None reported_count = raw_count try: if mode == "grid": if isinstance(raw_count, list) and len(raw_count) >= 3: count_vec = c4d.Vector( int(raw_count[0]), int(raw_count[1]), int(raw_count[2]) ) reported_count = [int(c) for c in count_vec] elif isinstance(raw_count, (int, float)): count_vec = c4d.Vector(int(raw_count), 1, 1) reported_count = [int(raw_count), 1, 1] else: self.log( f"[CLONER] ## Warning ## Invalid count '{raw_count}' for grid. Using defaults." ) count_vec = c4d.Vector(3, 1, 3) reported_count = [3, 1, 3] elif mode in ["linear", "radial", "object", "spline", "honeycomb"]: if isinstance(raw_count, list) and len(raw_count) >= 1: count_scalar = int(raw_count[0]) reported_count = count_scalar elif isinstance(raw_count, (int, float)): count_scalar = int(raw_count) reported_count = count_scalar else: self.log( f"[CLONER] ## Warning ## Invalid count '{raw_count}' for {mode}. Using default 10." ) count_scalar = 10 reported_count = 10 else: self.log( f"[CLONER] ## Warning ## Unsupported mode '{mode}'. Using default grid." ) mode = "grid" count_vec = c4d.Vector(3, 1, 3) reported_count = [3, 1, 3] except (ValueError, TypeError) as e: self.log( f"[CLONER] ## Warning ## Error parsing count '{raw_count}': {e}. Using defaults." ) if mode == "grid": count_vec = c4d.Vector(3, 1, 3) reported_count = [3, 1, 3] else: count_scalar = 10 reported_count = 10 self.log( f"[C4D CLONER] Creating: Name='{requested_name}', Mode='{mode}', Count='{reported_count}', Source='{object_identifier}' (GUID: {use_child_guid})" ) clone_obj_target = None child_obj_details = {"source": "Default Cube"} child_guid = None child_actual_name = "Default Cube" if clone_child_provided: clone_obj_target = self.find_object_by_name( doc, object_identifier, use_guid=use_child_guid ) if not clone_obj_target: search_type = "GUID" if use_child_guid else "Name" return { "error": f"Object '{object_identifier}' (searched by {search_type}) not found to clone." } else: target_name = clone_obj_target.GetName() target_guid = str(clone_obj_target.GetGUID()) child_obj_details["source"] = ( f"Existing Object: '{target_name}' (GUID: {target_guid})" ) self.log( f"[CLONER] Found clone object: '{target_name}' (GUID: {target_guid})" ) def create_mograph_cloner_safe( doc, name, mode, count, count_vec, found_clone_object ): nonlocal child_guid, child_actual_name try: cloner = c4d.BaseObject(c4d.Omgcloner) if not cloner: raise RuntimeError("Failed to create Cloner object") cloner.SetName(name) mode_ids = { "linear": 0, "radial": 2, "grid": 1, "object": 3, "spline": 4, "honeycomb": 5, } mode_id = mode_ids.get(mode, 1) doc.StartUndo() doc.InsertObject(cloner) doc.AddUndo(c4d.UNDOTYPE_NEW, cloner) cloner[c4d.ID_MG_MOTIONGENERATOR_MODE] = mode_id if found_clone_object: child_obj = found_clone_object.GetClone() else: child_obj = c4d.BaseObject(c4d.Ocube) child_obj.SetName("Default Cube") child_obj.SetAbsScale(c4d.Vector(0.5, 0.5, 0.5)) if not child_obj: raise RuntimeError("Failed to create/clone child object") doc.InsertObject(child_obj) doc.AddUndo(c4d.UNDOTYPE_NEW, child_obj) child_actual_name = child_obj.GetName() child_guid = str(child_obj.GetGUID()) child_obj.InsertUnderLast(cloner) self.register_object_name( child_obj, ( found_clone_object.GetName() if found_clone_object else "Default Cube" ), ) mg_bc = cloner.GetDataInstance() if not mg_bc: raise RuntimeError("Failed to get MoGraph BaseContainer") # --- FIXED: Use getattr for potentially missing constants --- if mode == "linear": mg_bc[c4d.MG_LINEAR_COUNT] = count # Use getattr for MG_LINEAR_PERSTEP, provide default vector if missing perstep_id = getattr(c4d, "MG_LINEAR_PERSTEP", None) mode_id_param = getattr(c4d, "MG_LINEAR_MODE", None) perstep_mode_val = getattr( c4d, "MG_LINEAR_MODE_PERSTEP", 0 ) # Default to 0 if missing if perstep_id: mg_bc[perstep_id] = c4d.Vector(0, 50, 0) else: self.log("[CLONER] ## Warning ## MG_LINEAR_PERSTEP not found.") if mode_id_param: mg_bc[mode_id_param] = perstep_mode_val else: self.log("[CLONER] ## Warning ## MG_LINEAR_MODE not found.") self.log(f"[C4D CLONER] Set linear count: {count}") # --- END FIXED --- elif mode == "grid": version = c4d.GetC4DVersion() try: if version >= 2025000 and hasattr(c4d, "MGGRIDARRAY_MODE"): mg_bc[c4d.MGGRIDARRAY_MODE] = c4d.MGGRIDARRAY_MODE_ENDPOINT mg_bc[c4d.MGGRIDARRAY_RESOLUTION] = count_vec mg_bc[c4d.MGGRIDARRAY_SIZE] = c4d.Vector(200, 200, 200) self.log( f"[C4D CLONER] Using 2025+ MGGRIDARRAY_*; resolution: {count_vec}" ) else: if ( hasattr(c4d, "MG_GRID_COUNT") and hasattr(c4d, "MG_GRID_MODE") and hasattr(c4d, "MG_GRID_SIZE") ): mg_bc[c4d.MG_GRID_COUNT] = count_vec mg_bc[c4d.MG_GRID_MODE] = c4d.MG_GRID_MODE_PERSTEP mg_bc[c4d.MG_GRID_SIZE] = c4d.Vector(100, 100, 100) self.log( f"[C4D CLONER] Using legacy MG_GRID_COUNT: {count_vec}, Mode: Per Step" ) else: if all( hasattr(c4d, attr) for attr in [ "MG_GRID_COUNT_X", "MG_GRID_COUNT_Y", "MG_GRID_COUNT_Z", "MG_CLONER_SIZE", ] ): mg_bc[c4d.MG_GRID_COUNT_X] = int(count_vec.x) mg_bc[c4d.MG_GRID_COUNT_Y] = int(count_vec.y) mg_bc[c4d.MG_GRID_COUNT_Z] = int(count_vec.z) mg_bc[c4d.MG_CLONER_SIZE] = c4d.Vector( 200, 200, 200 ) self.log( f"[C4D CLONER] Using legacy MG_GRID_COUNT_X/Y/Z: {count_vec}, Size: 200" ) else: self.log( "[C4D CLONER] ## Warning ##: Could not find suitable grid parameters." ) except Exception as e_grid: self.log( f"[C4D CLONER] ## Warning ## Grid mode config failed: {e_grid}" ) elif mode == "radial": if hasattr(c4d, "MG_POLY_COUNT") and hasattr(c4d, "MG_POLY_RADIUS"): mg_bc[c4d.MG_POLY_COUNT] = count mg_bc[c4d.MG_POLY_RADIUS] = 200 self.log(f"[C4D CLONER] Set radial count: {count}, Radius: 200") else: self.log( "[C4D CLONER] ## Warning ##: Radial parameters not found." ) elif mode == "object": self.log("[C4D CLONER] Object mode selected, requires linking.") if not hasattr(c4d, "MG_OBJECT_LINK"): self.log( "[C4D CLONER] ## Warning ##: Object link parameter not found." ) if hasattr(c4d, "MGCLONER_MODE"): cloner[c4d.MGCLONER_MODE] = c4d.MGCLONER_MODE_ITERATE doc.EndUndo() c4d.EventAdd() actual_cloner_name = cloner.GetName() cloner_guid = str(cloner.GetGUID()) pos_vec = cloner.GetAbsPos() self.register_object_name(cloner, name) # Use 'name' (requested name) return { "cloner": { "requested_name": name, "actual_name": actual_cloner_name, "guid": cloner_guid, "type": mode, "count_set": reported_count, "position": [pos_vec.x, pos_vec.y, pos_vec.z], "child_object": { "source": child_obj_details["source"], "actual_name": child_actual_name, "guid": child_guid, }, } } except Exception as e: doc.EndUndo() self.log( f"[**ERROR**] Exception during cloner creation safe wrapper: {str(e)}\n{traceback.format_exc()}" ) return { "error": f"Exception during cloner creation: {str(e)}", "traceback": traceback.format_exc(), } try: self.log("[C4D CLONER] Dispatching cloner creation to main thread") result = self.execute_on_main_thread( create_mograph_cloner_safe, args=( doc, requested_name, mode, count_scalar, count_vec, clone_obj_target, ), _timeout=30, ) if isinstance(result, dict) and "error" in result: self.log(f"[C4D CLONER] Error: {result['error']}") return result return result except Exception as e: self.log( f"[**ERROR**] Exception in cloner handler dispatch: {str(e)}\n{traceback.format_exc()}" ) return { "error": f"Exception dispatching cloner handler: {str(e)}", "traceback": traceback.format_exc(), } def handle_list_objects(self): """Handle list_objects command with comprehensive object detection including MoGraph objects.""" doc = c4d.documents.GetActiveDocument() objects = [] found_ids = set() # Track object IDs to avoid duplicates # Function to recursively get all objects including children with improved traversal def get_objects_recursive(start_obj, depth=0): current_obj = start_obj while current_obj: try: # Get object ID to avoid duplicates obj_id = str(current_obj.GetGUID()) # Skip if we've already processed this object if obj_id in found_ids: current_obj = current_obj.GetNext() continue found_ids.add(obj_id) # Get object name and type obj_name = current_obj.GetName() obj_type_id = current_obj.GetType() # Get basic object info with enhanced MoGraph detection obj_type = self.get_object_type_name(current_obj) # Additional properties dictionary for specific object types additional_props = {} # MoGraph Cloner enhanced detection - explicitly check for cloner type if obj_type_id == c4d.Omgcloner: obj_type = "MoGraph Cloner" try: # Get the cloner mode mode_id = current_obj[c4d.ID_MG_MOTIONGENERATOR_MODE] modes = { 0: "Linear", 1: "Grid", 2: "Radial", 3: "Object", } mode_name = modes.get(mode_id, f"Mode {mode_id}") additional_props["cloner_mode"] = mode_name # Add counts based on mode - using R2025.1 constant paths try: # Try R2025.1 module path first if mode_id == 0: # Linear if hasattr(c4d, "MG_LINEAR_COUNT"): additional_props["count"] = current_obj[ c4d.MG_LINEAR_COUNT ] elif mode_id == 1: # Grid if hasattr(c4d, "MGGRIDARRAY_RESOLUTION"): resolution = current_obj[ c4d.MGGRIDARRAY_RESOLUTION ] additional_props["count_x"] = int(resolution.x) additional_props["count_y"] = int(resolution.y) additional_props["count_z"] = int(resolution.z) # Fallback to legacy MG_GRID_COUNT_* if available elif all( hasattr(c4d, attr) for attr in [ "MG_GRID_COUNT_X", "MG_GRID_COUNT_Y", "MG_GRID_COUNT_Z", ] ): additional_props["count_x"] = int( current_obj[c4d.MG_GRID_COUNT_X] ) additional_props["count_y"] = int( current_obj[c4d.MG_GRID_COUNT_Y] ) additional_props["count_z"] = int( current_obj[c4d.MG_GRID_COUNT_Z] ) else: self.log( "[C4D CLONER WARNING] No valid grid count parameters found" ) elif mode_id == 2: # Radial if hasattr(c4d, "MG_POLY_COUNT"): additional_props["count"] = current_obj[ c4d.MG_POLY_COUNT ] except Exception as e: self.log( f"[C4D CLONER] Error getting cloner counts: {str(e)}" ) self.log( f"[C4D CLONER] Detected MoGraph Cloner: {obj_name}, Mode: {mode_name}" ) except Exception as e: self.log( f"[C4D CLONER] Error getting cloner details: {str(e)}" ) # MoGraph Effector enhanced detection elif 1019544 <= obj_type_id <= 1019644: if obj_type_id == c4d.Omgrandom: obj_type = "Random Effector" elif obj_type_id == c4d.Omgformula: obj_type = "Formula Effector" elif hasattr(c4d, "Omgstep") and obj_type_id == c4d.Omgstep: obj_type = "Step Effector" else: obj_type = "MoGraph Effector" # Try to get effector strength try: if hasattr(c4d, "ID_MG_BASEEFFECTOR_STRENGTH"): additional_props["strength"] = current_obj[ c4d.ID_MG_BASEEFFECTOR_STRENGTH ] except: pass # Field objects enhanced detection elif 1039384 <= obj_type_id <= 1039484: field_types = { 1039384: "Spherical Field", 1039385: "Box Field", 1039386: "Cylindrical Field", 1039387: "Torus Field", 1039388: "Cone Field", 1039389: "Linear Field", 1039390: "Radial Field", 1039394: "Noise Field", } obj_type = field_types.get(obj_type_id, "Field") # Try to get field strength try: if hasattr(c4d, "FIELD_STRENGTH"): additional_props["strength"] = current_obj[ c4d.FIELD_STRENGTH ] except: pass # Base object info obj_info = { "id": obj_id, "name": obj_name, "type": obj_type, "type_id": obj_type_id, "level": depth, **additional_props, # Include any additional properties } # Position if hasattr(current_obj, "GetAbsPos"): pos = current_obj.GetAbsPos() obj_info["position"] = [pos.x, pos.y, pos.z] # Rotation (converted to degrees) if hasattr(current_obj, "GetRelRot"): rot = current_obj.GetRelRot() obj_info["rotation"] = [ c4d.utils.RadToDeg(rot.x), c4d.utils.RadToDeg(rot.y), c4d.utils.RadToDeg(rot.z), ] # Scale if hasattr(current_obj, "GetAbsScale"): scale = current_obj.GetAbsScale() obj_info["scale"] = [scale.x, scale.y, scale.z] # Add to the list objects.append(obj_info) # Recurse children if current_obj.GetDown(): get_objects_recursive(current_obj.GetDown(), depth + 1) # Move to next object current_obj = current_obj.GetNext() except Exception as e: self.log(f"[C4D CLONER] Error processing object: {str(e)}") if current_obj: current_obj = current_obj.GetNext() def get_all_root_objects(): # Start with standard objects get_objects_recursive(doc.GetFirstObject()) # Also check for MoGraph objects that might not be in main hierarchy # (This is more for thoroughness as get_objects_recursive should find everything) try: if hasattr(c4d, "GetMoData"): mograph_data = c4d.GetMoData(doc) if mograph_data: for i in range(mograph_data.GetCount()): obj = mograph_data.GetObject(i) if obj and obj.GetType() == c4d.Omgcloner: if str(obj.GetGUID()) not in found_ids: get_objects_recursive(obj) except Exception as e: self.log(f"[**ERROR**] Error checking MoGraph objects: {str(e)}") # Get all objects starting from the root level get_all_root_objects() self.log( f"[C4D] Comprehensive object search complete, found {len(objects)} objects" ) return {"objects": objects} def handle_add_effector(self, command): """Adds a MoGraph effector and optionally links it to a cloner, returns context.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} type_name = command.get("effector_type", "random").lower() # --- Use 'target' preferentially, fallback to 'cloner_name' --- cloner_identifier = command.get("target") or command.get("cloner_name") or "" properties = command.get("properties", {}) requested_name = ( command.get("name") or command.get("effector_name") or f"{type_name.capitalize()} Effector" ) # --- Detect if the cloner_identifier looks like a GUID --- use_cloner_guid = False if cloner_identifier: # Check only if identifier exists identifier_str = str(cloner_identifier) if "-" in identifier_str and len(identifier_str) > 30: use_cloner_guid = True elif identifier_str.isdigit() or ( identifier_str.startswith("-") and identifier_str[1:].isdigit() ): if len(identifier_str) > 10: use_cloner_guid = True # --- End GUID detection --- effector = None try: self.log( f"[C4D EFFECTOR] Creating {type_name} effector named '{requested_name}'" ) if cloner_identifier: self.log( f"[C4D EFFECTOR] Will attempt to apply to cloner '{cloner_identifier}' (Treat as GUID: {use_cloner_guid})" ) # (Effector type mapping and creation remains the same) effector_types = { "random": c4d.Omgrandom, "formula": c4d.Omgformula, "step": c4d.Omgstep, "target": getattr( c4d, "Omgtarget", getattr(c4d, "Omgeffectortarget", None) ), "time": c4d.Omgtime, "sound": c4d.Omgsound, "plain": c4d.Omgplain, "delay": c4d.Omgdelay, "spline": c4d.Omgspline, "python": c4d.Omgpython, "shader": c4d.Omgshader, "volume": c4d.Omgvolume, } if hasattr(c4d, "Omgfalloff"): effector_types["falloff"] = c4d.Omgfalloff effector_id = effector_types.get(type_name) if effector_id is None: return {"error": f"Unsupported effector type: {type_name}"} doc.StartUndo() effector = c4d.BaseObject(effector_id) if effector is None: raise RuntimeError(f"Failed to create {type_name} effector BaseObject") effector.SetName(requested_name) # (Property setting remains the same) bc = effector.GetDataInstance() if bc: if "strength" in properties and isinstance( properties["strength"], (int, float) ): try: bc[c4d.ID_MG_BASEEFFECTOR_STRENGTH] = ( float(properties["strength"]) / 100.0 ) except Exception as e_prop: self.log(f"Warning: Could not set strength: {e_prop}") if "position_mode" in properties and isinstance( properties["position_mode"], bool ): try: bc[c4d.ID_MG_BASEEFFECTOR_POSITION_ACTIVE] = properties[ "position_mode" ] except Exception as e_prop: self.log(f"Warning: Could not set position_mode: {e_prop}") if "rotation_mode" in properties and isinstance( properties["rotation_mode"], bool ): try: bc[c4d.ID_MG_BASEEFFECTOR_ROTATION_ACTIVE] = properties[ "rotation_mode" ] except Exception as e_prop: self.log(f"Warning: Could not set rotation_mode: {e_prop}") if "scale_mode" in properties and isinstance( properties["scale_mode"], bool ): try: bc[c4d.ID_MG_BASEEFFECTOR_SCALE_ACTIVE] = properties[ "scale_mode" ] except Exception as e_prop: self.log(f"Warning: Could not set scale_mode: {e_prop}") else: self.log( f"Warning: Could not get BaseContainer for effector '{requested_name}'" ) doc.InsertObject(effector) doc.AddUndo(c4d.UNDOTYPE_NEW, effector) # --- Linking logic (remains the same, but find_object_by_name call uses correct flag now) --- cloner_applied_to_name = "None" cloner_applied_to_guid = None cloner_found = None if cloner_identifier: # Pass the use_cloner_guid flag correctly cloner_found = self.find_object_by_name( doc, cloner_identifier, use_guid=use_cloner_guid ) if cloner_found is None: search_type = "GUID" if use_cloner_guid else "Name" self.log( f"[C4D EFFECTOR] ## Warning ##: Cloner '{cloner_identifier}' (searched by {search_type}) not found, effector created but not linked." ) else: if cloner_found.GetType() != c4d.Omgcloner: self.log( f"[C4D EFFECTOR] ## Warning ##: Target '{cloner_found.GetName()}' is not a MoGraph Cloner (Type: {cloner_found.GetType()})" ) else: try: effector_list = None try: effector_list = cloner_found[ c4d.ID_MG_MOTIONGENERATOR_EFFECTORLIST ] except: self.log( f"[C4D EFFECTOR] Creating new effector list for cloner '{cloner_found.GetName()}'" ) if not isinstance(effector_list, c4d.InExcludeData): effector_list = c4d.InExcludeData() effector_list.InsertObject(effector, 1) cloner_found[c4d.ID_MG_MOTIONGENERATOR_EFFECTORLIST] = ( effector_list ) doc.AddUndo(c4d.UNDOTYPE_CHANGE, cloner_found) cloner_applied_to_name = cloner_found.GetName() cloner_applied_to_guid = str(cloner_found.GetGUID()) self.log( f"[C4D EFFECTOR] Successfully applied effector to cloner '{cloner_applied_to_name}'" ) except Exception as e_apply: self.log( f"[**ERROR**] Error applying effector to cloner '{cloner_found.GetName()}': {str(e_apply)}" ) doc.EndUndo() c4d.EventAdd() # --- Contextual Return (remains the same) --- actual_effector_name = effector.GetName() effector_guid = str(effector.GetGUID()) pos_vec = effector.GetAbsPos() self.register_object_name(effector, requested_name) return { "effector": { "requested_name": requested_name, "actual_name": actual_effector_name, "guid": effector_guid, "type": type_name, "position": [pos_vec.x, pos_vec.y, pos_vec.z], "applied_to_cloner_name": cloner_applied_to_name, "applied_to_cloner_guid": cloner_applied_to_guid, } } except Exception as e: doc.EndUndo() self.log( f"[**ERROR**] Error creating effector: {str(e)}\n{traceback.format_exc()}" ) if effector and not effector.GetDocument(): try: effector.Remove() except: pass return { "error": f"Failed to create effector: {str(e)}", "traceback": traceback.format_exc(), } def handle_apply_mograph_fields(self, command): """Applies a MoGraph field (as a child) to a MoGraph effector, returns context.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} field_type = command.get("field_type", "spherical").lower() requested_name = command.get("field_name", f"{field_type.capitalize()} Field") target_identifier = command.get("target_name", "") parameters = command.get("parameters", {}) # --- REVISED: Detect if target_identifier is likely a GUID --- use_target_guid = False if target_identifier: identifier_str = str(target_identifier) if "-" in identifier_str and len(identifier_str) > 30: use_target_guid = True elif identifier_str.isdigit() or ( identifier_str.startswith("-") and identifier_str[1:].isdigit() ): if len(identifier_str) > 10: use_target_guid = True # --- END REVISED --- field = None try: self.log( f"[C4D FIELDS] Request: Field='{requested_name}' Type='{field_type}' Target='{target_identifier}' (Treat as GUID: {use_target_guid})" ) target = self.find_object_by_name( doc, target_identifier, use_guid=use_target_guid ) if not target: search_type = "GUID" if use_target_guid else "Name" return { "error": f"Target effector '{target_identifier}' (searched by {search_type}) not found" } valid_effector_types = { c4d.Omgplain, c4d.Omgrandom, c4d.Omgstep, c4d.Omgdelay, c4d.Omgformula, c4d.Omgtime, c4d.Omgsound, c4d.Omgpython, c4d.Omgshader, c4d.Omgvolume, getattr(c4d, "Omgtarget", getattr(c4d, "Omgeffectortarget", None)), } if target.GetType() not in valid_effector_types: return { "error": f"Target '{target.GetName()}' is not a supported effector type (Type: {target.GetType()})" } target_name = target.GetName() target_guid = str(target.GetGUID()) field_type_map = { "spherical": getattr(c4d, "Fspherical", 440000243), "box": getattr(c4d, "Fbox", 440000244), "radial": getattr(c4d, "Fradial", 440000245), "linear": getattr(c4d, "Flinear", 440000246), "noise": 440000248, "cylinder": getattr(c4d, "Fcylinder", 1039386), "cone": getattr(c4d, "Fcone", 1039388), "torus": getattr(c4d, "Ftorus", 1039387), "formula": getattr(c4d, "Fformula", 1040830), "random": getattr(c4d, "Frandom", 1040831), "step": getattr(c4d, "Fstep", 1040832), } field_type_id = field_type_map.get(field_type) if not field_type_id: return {"error": f"Unsupported field type: '{field_type}'"} doc.StartUndo() field = c4d.BaseObject(field_type_id) if not field: raise RuntimeError("Failed to create field BaseObject") field.SetName(requested_name) bc = field.GetDataInstance() if bc: if ( "position" in parameters and isinstance(parameters["position"], list) and len(parameters["position"]) >= 3 ): try: field.SetAbsPos( c4d.Vector(*[float(p) for p in parameters["position"][:3]]) ) except (ValueError, TypeError): self.log( f"Warning: Invalid field position {parameters['position']}" ) if ( "scale" in parameters and isinstance(parameters["scale"], list) and len(parameters["scale"]) >= 3 ): try: field.SetAbsScale( c4d.Vector(*[float(p) for p in parameters["scale"][:3]]) ) except (ValueError, TypeError): self.log(f"Warning: Invalid field scale {parameters['scale']}") if ( "rotation" in parameters and isinstance(parameters["rotation"], list) and len(parameters["rotation"]) >= 3 ): try: hpb_rad = [ c4d.utils.DegToRad(float(angle)) for angle in parameters["rotation"][:3] ] field.SetAbsRot(c4d.Vector(*hpb_rad)) except (ValueError, TypeError): self.log( f"Warning: Invalid field rotation {parameters['rotation']}" ) if field_type == "spherical" and "radius" in parameters: radius_id = getattr( c4d, "FIELD_SIZE", getattr(c4d, "FIELDSPHERICAL_RADIUS", None) ) if radius_id: try: bc[radius_id] = float(parameters["radius"]) except (ValueError, TypeError): self.log( f"Warning: Invalid radius value {parameters['radius']}" ) else: self.log( "Warning: Could not find radius parameter ID for spherical field." ) else: self.log( f"Warning: Could not get BaseContainer for field '{requested_name}'" ) doc.InsertObject(field) doc.AddUndo(c4d.UNDOTYPE_NEW, field) field.InsertUnder(target) doc.AddUndo(c4d.UNDOTYPE_CHANGE, target) doc.EndUndo() c4d.EventAdd() self.log( f"[C4D FIELDS] Linked field '{field.GetName()}' to effector '{target_name}'" ) actual_field_name = field.GetName() field_guid = str(field.GetGUID()) pos_vec = field.GetAbsPos() self.register_object_name(field, requested_name) return { "field": { "requested_name": requested_name, "actual_name": actual_field_name, "guid": field_guid, "type": field_type, "target_name": target_name, "target_guid": target_guid, "position": [pos_vec.x, pos_vec.y, pos_vec.z], } } except Exception as e: doc.EndUndo() self.log(f"[**ERROR**] Error applying field: {e}\n{traceback.format_exc()}") if field and not field.GetDocument(): try: field.Remove() except: pass return { "error": f"Exception occurred applying field: {str(e)}", "traceback": traceback.format_exc(), } def handle_create_soft_body(self, command): """Handle create_soft_body command with GUID support.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # --- MODIFIED: Identify target object --- identifier = None use_guid = False if command.get("guid"): identifier = command.get("guid") use_guid = True self.log(f"[SOFT BODY] Using GUID identifier: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") use_guid = False self.log(f"[SOFT BODY] Using Name identifier: '{identifier}'") else: return {"error": "No object identifier ('guid' or 'object_name') provided."} # Find target object using the determined method obj = self.find_object_by_name(doc, identifier, use_guid=use_guid) if obj is None: search_type = "GUID" if use_guid else "Name" return { "error": f"Object '{identifier}' (searched by {search_type}) not found for soft body." } # --- END MODIFIED --- # Get parameters (using original logic) name = command.get( "name", f"{obj.GetName()} Soft Body" ) # Default name based on object stiffness = command.get("stiffness", 50) mass = command.get("mass", 1.0) # Define safe wrapper (using original logic, but noting potential RIGID_BODY_SOFTBODY issue) def create_soft_body_safe( target_obj, tag_name, stiff_val, mass_val, obj_actual_name ): self.log( f"[C4D SBODY] Creating soft body dynamics tag '{tag_name}' for object '{obj_actual_name}'" ) dynamics_tag_id = 180000102 # Standard Dynamics Body Tag ID tag = c4d.BaseTag(dynamics_tag_id) if tag is None: self.log( f"[C4D SBODY] Error: Failed to create Dynamics Body tag with ID {dynamics_tag_id}" ) raise RuntimeError("Failed to create Dynamics Body tag") tag.SetName(tag_name) self.log(f"[C4D SBODY] Successfully created dynamics tag: {tag_name}") # --- Potential Issue Area --- # RIGID_BODY_SOFTBODY might be deprecated in newer C4D versions. # This might need adjustment based on testing with the target C4D version. # A more modern approach uses RIGID_BODY_TYPE = 2 try: # Try modern approach first if hasattr(c4d, "RIGID_BODY_TYPE"): tag[c4d.RIGID_BODY_TYPE] = getattr( c4d, "RIGID_BODY_TYPE_SOFTBODY", 2 ) # Use constant or fallback value 2 self.log( f"[C4D SBODY] Set RIGID_BODY_TYPE to Soft Body ({tag[c4d.RIGID_BODY_TYPE]})" ) elif hasattr(c4d, "RIGID_BODY_SOFTBODY"): # Fallback to older attribute if modern one doesn't exist tag[c4d.RIGID_BODY_SOFTBODY] = True self.log("[C4D SBODY] Set RIGID_BODY_SOFTBODY to True (legacy)") else: self.log( "[C4D SBODY] ## Warning ##: Cannot find suitable parameter to enable Soft Body mode." ) # Common properties (assuming these IDs are stable) tag[c4d.RIGID_BODY_DYNAMIC] = 1 # Enable dynamics tag[c4d.RIGID_BODY_MASS] = float(mass_val) # Stiffness might also have changed ID, add check softbody_stiffness_id = getattr( c4d, "RIGID_BODY_SOFTBODY_STIFFNESS", 1110 ) # Example ID 1110 if tag.HasParameter(softbody_stiffness_id): tag[softbody_stiffness_id] = ( float(stiff_val) / 100.0 ) # Assume 0-100 input self.log( f"[C4D SBODY] Set stiffness parameter ID {softbody_stiffness_id}" ) else: self.log( f"[C4D SBODY] ## Warning ##: Stiffness parameter ID {softbody_stiffness_id} not found." ) except AttributeError as ae: self.log( f"[**ERROR**] Missing Dynamics attribute: {ae}. Dynamics setup might be incomplete." ) # Don't raise, just log, tag might still be useful partially except Exception as e_tag: self.log(f"[**ERROR**] Error setting dynamics parameters: {e_tag}") # Don't raise, try inserting tag anyway target_obj.InsertTag(tag) doc.AddUndo(c4d.UNDOTYPE_NEW, tag) c4d.EventAdd() # Return context return { "object_name": obj_actual_name, "object_guid": str(target_obj.GetGUID()), # Added GUID "tag_name": tag.GetName(), "stiffness_set": float(stiff_val), # Report value requested "mass_set": float(mass_val), # Report value requested } # Execute on main thread try: result = self.execute_on_main_thread( create_soft_body_safe, args=(obj, name, stiffness, mass, obj.GetName()), ) # Check result structure from execute_on_main_thread if isinstance(result, dict) and "error" in result: return result # Propagate error elif isinstance(result, dict) and result.get("status") == "completed_none": return { "error": "Soft body creation function returned None unexpectedly." } else: return {"soft_body": result} # Wrap successful result except Exception as e: # Catch errors related to execute_on_main_thread itself self.log( f"[**ERROR**] Failed to execute soft body creation via main thread: {e}\n{traceback.format_exc()}" ) return {"error": f"Failed to queue/execute Soft Body creation: {str(e)}"} def handle_apply_dynamics(self, command): """Handle apply_dynamics command with GUID support.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # --- MODIFIED: Identify target object --- identifier = None use_guid = False if command.get("guid"): identifier = command.get("guid") use_guid = True self.log(f"[DYNAMICS] Using GUID identifier: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") use_guid = False self.log(f"[DYNAMICS] Using Name identifier: '{identifier}'") else: return {"error": "No object identifier ('guid' or 'object_name') provided."} # Find target object using the determined method obj = self.find_object_by_name(doc, identifier, use_guid=use_guid) if obj is None: search_type = "GUID" if use_guid else "Name" return { "error": f"Object '{identifier}' (searched by {search_type}) not found for dynamics." } # --- END MODIFIED --- tag_type = command.get("tag_type", "rigid_body").lower() params = command.get("parameters", {}) tag_name = command.get( "tag_name", f"{obj.GetName()} {tag_type.replace('_',' ').title()}" ) # Default name try: # Use Tdynamicsbody if available, fallback to old ID dynamics_tag_id = getattr(c4d, "Tdynamicsbody", 180000102) self.log( f"[DYNAMICS] Using Dynamics Tag ID: {dynamics_tag_id} for type '{tag_type}'" ) doc.StartUndo() # Start undo block tag = obj.MakeTag(dynamics_tag_id) # Use MakeTag for safer insertion if tag is None: raise RuntimeError( f"Failed to create Dynamics tag (ID: {dynamics_tag_id}) on '{obj.GetName()}'" ) tag.SetName(tag_name) bc = tag.GetDataInstance() if not bc: raise RuntimeError("Failed to get BaseContainer for dynamics tag") # Map tag_type string to RIGID_BODY_TYPE enum value type_map = { "rigid_body": getattr(c4d, "RIGID_BODY_TYPE_RIGIDBODY", 1), # Usually 1 "collider": getattr(c4d, "RIGID_BODY_TYPE_COLLIDER", 0), # Usually 0 "ghost": getattr(c4d, "RIGID_BODY_TYPE_GHOST", 3), # Usually 3 "soft_body": getattr(c4d, "RIGID_BODY_TYPE_SOFTBODY", 2), # Usually 2 } dynamics_type = type_map.get(tag_type) if dynamics_type is None: self.log( f"Warning: Unknown dynamics tag_type '{tag_type}'. Defaulting to Collider." ) dynamics_type = type_map["collider"] # Set dynamics type and enable bc[c4d.RIGID_BODY_TYPE] = dynamics_type bc[c4d.RIGID_BODY_ENABLED] = True # Apply common parameters from the 'params' dictionary safely if "mass" in params: try: bc[c4d.RIGID_BODY_MASS_TYPE] = getattr( c4d, "RIGID_BODY_MASS_TYPE_CUSTOM", 1 ) bc[c4d.RIGID_BODY_MASS_CUSTOM] = float(params["mass"]) except (ValueError, TypeError, AttributeError) as e: self.log( f"Warning: Invalid/unsupported mass value '{params['mass']}': {e}" ) if "friction" in params: try: bc[c4d.RIGID_BODY_FRICTION] = float(params["friction"]) except (ValueError, TypeError, AttributeError) as e: self.log( f"Warning: Invalid/unsupported friction value '{params['friction']}': {e}" ) # Use BOUNCE as it's the more common ID name than ELASTICITY if "bounce" in params or "elasticity" in params: bounce_val = params.get( "bounce", params.get("elasticity") ) # Accept either key try: bc[c4d.RIGID_BODY_BOUNCE] = float(bounce_val) except (ValueError, TypeError, AttributeError) as e: self.log( f"Warning: Invalid/unsupported bounce/elasticity value '{bounce_val}': {e}" ) if "collision_shape" in params: shape_map = { "auto": getattr(c4d, "RIGID_BODY_SHAPE_AUTO", 0), "box": getattr(c4d, "RIGID_BODY_SHAPE_BOX", 1), "sphere": getattr(c4d, "RIGID_BODY_SHAPE_SPHERE", 2), "capsule": getattr(c4d, "RIGID_BODY_SHAPE_CAPSULE", 3), "cylinder": getattr(c4d, "RIGID_BODY_SHAPE_CYLINDER", 4), "cone": getattr(c4d, "RIGID_BODY_SHAPE_CONE", 5), "static_mesh": getattr(c4d, "RIGID_BODY_SHAPE_STATICMESH", 7), "moving_mesh": getattr(c4d, "RIGID_BODY_SHAPE_MOVINGMESH", 8), } shape_val = shape_map.get(str(params["collision_shape"]).lower()) if shape_val is not None: try: bc[c4d.RIGID_BODY_SHAPE] = shape_val except AttributeError as e: self.log(f"Warning: Collision shape parameter not found: {e}") else: self.log( f"Warning: Invalid collision_shape value '{params['collision_shape']}'" ) else: # Default collision shape if not specified try: bc[c4d.RIGID_BODY_SHAPE] = getattr(c4d, "RIGID_BODY_SHAPE_AUTO", 0) except AttributeError as e: self.log( f"Warning: Default collision shape parameter not found: {e}" ) # No need for obj.InsertTag(tag) because MakeTag already inserts it doc.AddUndo(c4d.UNDOTYPE_NEW, tag) # Add undo for the new tag doc.EndUndo() # End undo block c4d.EventAdd() # --- MODIFIED: Contextual Return --- return { "dynamics": { "object_name": obj.GetName(), "object_guid": str(obj.GetGUID()), "tag_name": tag.GetName(), "tag_type_applied": tag_type, "parameters_received": params, # Echo back received params for verification } } # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo is ended on error self.log( f"[**ERROR**] Error applying dynamics: {e}\n{traceback.format_exc()}" ) return { "error": f"Failed to apply Dynamics tag: {str(e)}", "traceback": traceback.format_exc(), } def handle_create_abstract_shape(self, command): """Handle create_abstract_shape command with context and C4D 2025 compatibility.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} shape_type = command.get("shape_type", "metaball").lower() # Accept both "name" and "object_name" requested_name = command.get("name") or command.get( "object_name", f"{shape_type.capitalize()}" ) position_list = command.get("position", [0, 0, 0]) # Safely parse position position = [0.0, 0.0, 0.0] if isinstance(position_list, list) and len(position_list) >= 3: try: position = [float(p) for p in position_list[:3]] except (ValueError, TypeError): self.log(f"Warning: Invalid position data {position_list}") self.log( f"[C4D ABSTRCTSHAPE] Creating abstract shape '{shape_type}' with requested name: '{requested_name}'" ) shape = None # Initialize shape variable try: shape_types = { "metaball": 5125, "blob": 5119, "loft": 5107, "sweep": 5118, "atom": 5168, "platonic": 5170, "cloth": 5186, "landscape": 5119, "extrude": 5116, } shape_type_id = shape_types.get(shape_type, shape_types["metaball"]) self.log( f"[C4D ABSTRCTSHAPE] Creating abstract shape of type: {shape_type} (ID: {shape_type_id})" ) doc.StartUndo() # Start undo block shape = c4d.BaseObject(shape_type_id) if shape is None: raise RuntimeError(f"Failed to create {shape_type} object") shape.SetName(requested_name) shape.SetAbsPos(c4d.Vector(*position)) child_objects_context = {} # Store context for children # Add children based on type (using original logic) if shape_type in ["metaball", "blob"]: self.log(f"[C4D ABSTRCTSHAPE] Creating child sphere for {shape_type}") sphere = c4d.BaseObject(c4d.Osphere) if sphere: child_req_name = ( f"{requested_name}_Sphere" # Use requested name of parent ) sphere.SetName(child_req_name) sphere.SetAbsScale(c4d.Vector(2.0, 2.0, 2.0)) # Use floats bc = sphere.GetDataInstance() if bc: bc.SetFloat(c4d.PRIM_SPHERE_RAD, 50.0) # Use floats sphere.InsertUnder(shape) doc.AddUndo(c4d.UNDOTYPE_NEW, sphere) # Add child context child_actual_name = sphere.GetName() child_guid = str(sphere.GetGUID()) child_objects_context["sphere"] = { "requested_name": child_req_name, "actual_name": child_actual_name, "guid": child_guid, } self.register_object_name(sphere, child_req_name) # Register child else: self.log(f"Warning: Failed to create child sphere for {shape_type}") elif shape_type in ("loft", "sweep"): self.log( f"[C4D ABSTRCTSHAPE] Creating profile and path splines for {shape_type}" ) spline = c4d.BaseObject(c4d.Osplinecircle) path = c4d.BaseObject(c4d.Osplinenside) if spline: child_req_name = f"{requested_name}_Profile" spline.SetName(child_req_name) spline.InsertUnder(shape) doc.AddUndo(c4d.UNDOTYPE_NEW, spline) child_actual_name = spline.GetName() child_guid = str(spline.GetGUID()) child_objects_context["profile"] = { "requested_name": child_req_name, "actual_name": child_actual_name, "guid": child_guid, } self.register_object_name(spline, child_req_name) else: self.log("Warning: Failed to create profile spline") if path: child_req_name = f"{requested_name}_Path" path.SetName(child_req_name) path.SetAbsPos(c4d.Vector(0, 50, 0)) path.InsertUnder(shape) doc.AddUndo(c4d.UNDOTYPE_NEW, path) child_actual_name = path.GetName() child_guid = str(path.GetGUID()) child_objects_context["path"] = { "requested_name": child_req_name, "actual_name": child_actual_name, "guid": child_guid, } self.register_object_name(path, child_req_name) else: self.log("Warning: Failed to create path spline") # Insert the main shape object doc.InsertObject(shape) doc.AddUndo(c4d.UNDOTYPE_NEW, shape) doc.EndUndo() # End undo block c4d.EventAdd() # --- MODIFIED: Contextual Return --- actual_name = shape.GetName() guid = str(shape.GetGUID()) pos_vec = shape.GetAbsPos() shape_type_name = self.get_object_type_name(shape) # Get user friendly name # Register the main shape object self.register_object_name(shape, requested_name) return { "shape": { "requested_name": requested_name, "actual_name": actual_name, "guid": guid, "type": shape_type_name, # User friendly type "type_id": shape.GetType(), # C4D ID "position": [pos_vec.x, pos_vec.y, pos_vec.z], "child_objects": child_objects_context, # Include context of children } } # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo is ended on error self.log( f"[**ERROR**] Error creating abstract shape '{requested_name}': {str(e)}\n{traceback.format_exc()}" ) # Clean up shape if created but not inserted if shape and not shape.GetDocument(): try: shape.Remove() except: pass return { "error": f"Failed to create abstract shape: {str(e)}", "traceback": traceback.format_exc(), } def _find_by_guid_recursive(self, start_obj, guid): """Recursively search for an object with a specific GUID.""" current_obj = start_obj while current_obj: if str(current_obj.GetGUID()) == guid: return current_obj # Check children recursively child = current_obj.GetDown() if child: result = self._find_by_guid_recursive(child, guid) if result: return result current_obj = current_obj.GetNext() return None def _get_all_objects(self, doc): """Get all objects in the document for efficient searching. This method uses optimal strategies for Cinema 4D 2025 to collect all objects in the scene without missing anything. """ all_objects = [] found_ids = set() # To avoid duplicates # Method 1: Standard hierarchy traversal def collect_recursive(obj): if obj is None: return obj_id = str(obj.GetGUID()) if obj_id not in found_ids: all_objects.append(obj) found_ids.add(obj_id) # Get children child = obj.GetDown() if child: collect_recursive(child) # Get siblings next_obj = obj.GetNext() if next_obj: collect_recursive(next_obj) # Start collection from root collect_recursive(doc.GetFirstObject()) # Method 2: Use GetObjects API if available in this version try: if hasattr(doc, "GetObjects"): objects = doc.GetObjects() for obj in objects: obj_id = str(obj.GetGUID()) if obj_id not in found_ids: all_objects.append(obj) found_ids.add(obj_id) except Exception as e: self.log(f"[**ERROR**] Error using GetObjects API: {str(e)}") # Method 3: Check for any missed MoGraph objects try: # Direct check for Cloners if hasattr(c4d, "Omgcloner"): # Use object type filtering to find cloners for obj in all_objects[:]: # Use a copy to avoid modification issues if ( obj.GetType() == c4d.Omgcloner and str(obj.GetGUID()) not in found_ids ): all_objects.append(obj) found_ids.add(str(obj.GetGUID())) except Exception as e: self.log(f"[**ERROR**] Error checking for MoGraph objects: {str(e)}") self.log(f"[C4D] Found {len(all_objects)} objects in document") return all_objects def handle_create_light(self, command): """Light creation with context and EXACT 2025.0 SDK parameters""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} light_type = command.get("type", "spot").lower() # Use requested name or generate one requested_name = ( command.get("name") or command.get("object_name") or f"MCP_{light_type.capitalize()}Light_{int(time.time()) % 1000}" ) # Handle test harness name if provided if not requested_name and command.get("from_test_harness"): requested_name = "Test_Light" position_list = command.get("position", [0, 100, 0]) color_list = command.get("color", [1, 1, 1]) intensity = command.get("intensity", 100) temperature = command.get("temperature", 6500) width = command.get("width", 200) height = command.get("height", 200) LIGHT_TYPE_MAP = {"point": 0, "spot": 1, "area": 8, "infinite": 3} if light_type not in LIGHT_TYPE_MAP: valid_types = ", ".join(LIGHT_TYPE_MAP.keys()) return { "error": f"Invalid light type: '{light_type}'. Valid: {valid_types}" } light = None # Initialize light variable try: doc.StartUndo() # Start undo block light = c4d.BaseObject(c4d.Olight) if not light: raise RuntimeError("Light creation failed") light_code = LIGHT_TYPE_MAP[light_type] light[c4d.LIGHT_TYPE] = light_code light.SetName(requested_name) self.log( f"[C4D LIGHT] Set requested name '{requested_name}' before insertion." ) # Log name set # Safely set position, color, brightness try: light.SetAbsPos(c4d.Vector(*[float(x) for x in position_list[:3]])) except (ValueError, TypeError): self.log(f"Warning: Invalid light position {position_list}") try: light[c4d.LIGHT_COLOR] = c4d.Vector( *[max(0.0, min(1.0, float(c))) for c in color_list[:3]] ) # Clamp color 0-1 except (ValueError, TypeError): self.log(f"Warning: Invalid light color {color_list}") try: light[c4d.LIGHT_BRIGHTNESS] = max( 0.0, float(intensity) / 100.0 ) # Clamp brightness >= 0 except (ValueError, TypeError): self.log(f"Warning: Invalid light intensity {intensity}") # Temperature handling if hasattr(c4d, "LIGHT_TEMPERATURE"): try: light[c4d.LIGHT_TEMPERATURE] = int(float(temperature)) except (TypeError, ValueError): self.log(f"Warning: Invalid temperature '{temperature}'") # Area light parameters if light_code == 8: # Area light try: light[c4d.LIGHT_AREADETAILS_SIZEX] = max( 0.0, float(width) ) # Ensure non-negative except (ValueError, TypeError): self.log(f"Warning: Invalid area light width {width}") try: light[c4d.LIGHT_AREADETAILS_SIZEY] = max( 0.0, float(height) ) # Ensure non-negative except (ValueError, TypeError): self.log(f"Warning: Invalid area light height {height}") try: light[c4d.LIGHT_AREADETAILS_SHAPE] = 0 # Rectangle except AttributeError: pass # Ignore if param doesn't exist # Shadow parameters if hasattr(c4d, "LIGHT_SHADOWTYPE"): try: light[c4d.LIGHT_SHADOWTYPE] = 1 # Soft shadows except AttributeError: pass # Ignore if param doesn't exist doc.InsertObject(light) doc.AddUndo(c4d.UNDOTYPE_NEW, light) # Add undo for new light doc.EndUndo() # End undo block c4d.EventAdd() # --- MODIFIED: Contextual Return --- actual_name = light.GetName() guid = str(light.GetGUID()) pos_vec = light.GetAbsPos() light_type_name = self.get_object_type_name(light) # Get user friendly name # Register the light object self.register_object_name(light, requested_name) return { "light": { # Changed key from 'object' to 'light' for clarity "requested_name": requested_name, "actual_name": actual_name, "guid": guid, "type": light_type_name, # User friendly type name "type_id": light.GetType(), # C4D ID "position": [pos_vec.x, pos_vec.y, pos_vec.z], # Optionally return other set properties for context "color_set": [ light[c4d.LIGHT_COLOR].x, light[c4d.LIGHT_COLOR].y, light[c4d.LIGHT_COLOR].z, ], "intensity_set": light[c4d.LIGHT_BRIGHTNESS] * 100.0, } } # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo is ended on error self.log( f"[**ERROR**] Error creating light '{requested_name}': {str(e)}\n{traceback.format_exc()}" ) # Clean up light if created but not inserted if light and not light.GetDocument(): try: light.Remove() except: pass return { "error": f"Light creation failed: {str(e)}", "traceback": traceback.format_exc(), } def handle_create_camera(self, command): """Create a new camera, optionally pointing it towards a target.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} requested_name = command.get("name", "Camera") position_list = command.get("position", [0, 0, 0]) properties = command.get( "properties", {} ) # Includes focal_length, aperture, target_position etc. # Safely parse position position = [0.0, 0.0, 0.0] if isinstance(position_list, list) and len(position_list) >= 3: try: position = [float(p) for p in position_list[:3]] except (ValueError, TypeError): self.log(f"Warning: Invalid camera position data {position_list}") camera = None try: doc.StartUndo() camera = c4d.BaseObject(c4d.Ocamera) if not camera: raise RuntimeError("Failed to create camera object") camera.SetName(requested_name) cam_pos_vec = c4d.Vector(*position) camera.SetAbsPos(cam_pos_vec) # --- Apply standard camera properties --- applied_properties = {} bc = camera.GetDataInstance() if bc: if "focal_length" in properties: try: val = float(properties["focal_length"]) focus_id = getattr(c4d, "CAMERAOBJECT_FOCUS", c4d.CAMERA_FOCUS) bc[focus_id] = val applied_properties["focal_length"] = val except (ValueError, TypeError, AttributeError) as e: self.log(f"Warning: Failed to set focal_length: {e}") if "aperture" in properties: try: val = float(properties["aperture"]) bc[c4d.CAMERAOBJECT_APERTURE] = val applied_properties["aperture"] = val except (ValueError, TypeError, AttributeError) as e: self.log(f"Warning: Failed to set aperture: {e}") # Add other properties like film offset here if needed... # --- NEW: Handle Target Position --- target_pos = None target_list = properties.get( "target_position" ) # Expect key "target_position" if isinstance(target_list, list) and len(target_list) >= 3: try: target_pos = c4d.Vector(*[float(p) for p in target_list[:3]]) except (ValueError, TypeError): self.log(f"Warning: Invalid target_position data {target_list}") else: # Default target to world origin if not specified target_pos = c4d.Vector(0, 0, 0) self.log( f"No target_position provided, defaulting camera target to world origin." ) if target_pos is not None: try: # Calculate direction vector direction = target_pos - cam_pos_vec direction.Normalize() # Calculate HPB rotation in radians hpb = c4d.utils.VectorToHPB(direction) # Apply rotation (SetAbsRot expects radians) camera.SetAbsRot(hpb) applied_properties["rotation_set_to_target"] = [ c4d.utils.RadToDeg(a) for a in [hpb.x, hpb.y, hpb.z] ] # Report degrees self.log( f"Pointed camera '{camera.GetName()}' towards target {target_list or '[0,0,0]'}" ) except Exception as e_rot: self.log( f"Warning: Failed to calculate or set camera rotation towards target: {e_rot}" ) # --- END NEW TARGET HANDLING --- doc.InsertObject(camera) doc.AddUndo(c4d.UNDOTYPE_NEW, camera) doc.SetActiveObject(camera) doc.EndUndo() c4d.EventAdd() self.log(f"[C4D] Created camera '{camera.GetName()}' at {position}") # --- Contextual Return --- actual_name = camera.GetName() guid = str(camera.GetGUID()) pos_vec = camera.GetAbsPos() rot_vec_rad = camera.GetAbsRot() # Get final rotation camera_type_name = self.get_object_type_name(camera) self.register_object_name(camera, requested_name) return { "camera": { "requested_name": requested_name, "actual_name": actual_name, "guid": guid, "type": camera_type_name, "type_id": camera.GetType(), "position": [pos_vec.x, pos_vec.y, pos_vec.z], "rotation": [ c4d.utils.RadToDeg(a) for a in [rot_vec_rad.x, rot_vec_rad.y, rot_vec_rad.z] ], # Return final rotation in degrees "properties_applied": applied_properties, } } except Exception as e: if doc and doc.IsUndoEnabled(): doc.EndUndo() # Ensure undo ended self.log( f"[**ERROR**] Error creating camera '{requested_name}': {str(e)}\n{traceback.format_exc()}" ) if camera and not camera.GetDocument(): try: camera.Remove() except: pass return { "error": f"Failed to create camera: {str(e)}", "traceback": traceback.format_exc(), } def handle_animate_camera(self, command): """Handle animate_camera command with context.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} # --- MODIFIED: Identify target camera --- identifier = None use_guid = False if command.get("guid"): # Check for GUID first identifier = command.get("guid") use_guid = True self.log(f"[ANIM CAM] Using GUID identifier: '{identifier}'") elif command.get("camera_name"): identifier = command.get("camera_name") use_guid = False self.log(f"[ANIM CAM] Using Name identifier: '{identifier}'") # --- END MODIFIED --- path_type = command.get("path_type", "linear").lower() positions = command.get("positions", []) frames = command.get("frames", []) create_camera = command.get("create_camera", False) camera_properties = command.get("camera_properties", {}) # e.g., focal_length camera = None camera_created = False requested_name = ( identifier if identifier else "Animated Camera" ) # Use identifier as requested name if provided # Find existing camera if identifier provided and not creating new if identifier and not create_camera: camera = self.find_object_by_name(doc, identifier, use_guid=use_guid) if camera and camera.GetType() != c4d.Ocamera: self.log( f"Warning: Object '{identifier}' found but is not a camera. Will create new." ) camera = None # Force creation elif camera is None: search_type = "GUID" if use_guid else "Name" self.log( f"Info: Camera '{identifier}' (searched by {search_type}) not found, will create a new one." ) # Create camera if needed if camera is None: doc.StartUndo() # Start undo if creating camera = c4d.BaseObject(c4d.Ocamera) if not camera: return {"error": "Failed to create camera object"} camera.SetName(requested_name) # Use requested name self.log(f"[ANIM CAM] Created new camera: {camera.GetName()}") camera_created = True # Apply properties if provided (using original logic, ensure safety) applied_properties = {} bc = camera.GetDataInstance() if bc: if "focal_length" in camera_properties: try: val = float(camera_properties["focal_length"]) focus_id = getattr(c4d, "CAMERAOBJECT_FOCUS", c4d.CAMERA_FOCUS) bc[focus_id] = val applied_properties["focal_length"] = val except (ValueError, TypeError, AttributeError) as e: self.log(f"Warning: Failed to set focal_length: {e}") if "aperture" in camera_properties: try: val = float(camera_properties["aperture"]) bc[c4d.CAMERAOBJECT_APERTURE] = val applied_properties["aperture"] = val except (ValueError, TypeError, AttributeError) as e: self.log(f"Warning: Failed to set aperture: {e}") # Add other properties as needed... doc.InsertObject(camera) doc.AddUndo(c4d.UNDOTYPE_NEW, camera) doc.SetActiveObject(camera) # Make active # Register the newly created camera self.register_object_name(camera, requested_name) doc.EndUndo() # End undo block for creation else: self.log(f"[ANIM CAM] Using existing camera: '{camera.GetName()}'") # --- Animation Logic --- try: doc.StartUndo() # Start undo for animation changes # Add default frames if only positions are provided if positions and not frames: frames = list(range(len(positions))) # Simple frame sequence if not positions or not frames or len(positions) != len(frames): # Allow animation types without positions/frames? e.g. wiggle? if path_type not in ["wiggle"]: # Add other position-less types here doc.EndUndo() # End undo as nothing happened yet return { "error": f"Invalid positions/frames data for animation type '{path_type}'. They must be arrays of equal length." } else: self.log( f"Info: No position/frame data provided for '{path_type}', proceeding if type supports it." ) keyframe_count = 0 frame_range_set = [] # Set keyframes for camera positions if provided if positions and frames: for pos, frame in zip(positions, frames): if isinstance(pos, list) and len(pos) >= 3: # Use internal helper which already includes AddUndo if self._set_position_keyframe(camera, frame, pos): keyframe_count += 1 else: self.log( f"Warning: Skipping invalid position data {pos} for frame {frame}" ) if frames: frame_range_set = [min(frames), max(frames)] # Handle spline path if requested and positions available path_guid = None # Store GUID of created path if path_type in ["spline", "spline_oriented"] and len(positions) > 1: self.log("[ANIM CAM] Creating spline path and alignment tag.") path = c4d.BaseObject(c4d.Ospline) path.SetName(f"{camera.GetName()} Path") points = [ c4d.Vector(p[0], p[1], p[2]) for p in positions if isinstance(p, list) and len(p) >= 3 ] if not points: self.log("Warning: No valid points for spline path creation.") else: path.ResizeObject(len(points)) for i, pt in enumerate(points): path.SetPoint(i, pt) doc.InsertObject(path) # Insert path into scene doc.AddUndo(c4d.UNDOTYPE_NEW, path) path_guid = str(path.GetGUID()) # Get GUID after insertion self.register_object_name( path, path.GetName() ) # Register the path spline # Create and apply Align to Spline tag align_tag = camera.MakeTag(c4d.Talignspline) # Use Talignspline if align_tag: align_tag[c4d.ALIGNTOSPLINETAG_LINK] = ( path # Link the path object ) # Set Tangential if spline_oriented? Check specific tag params if needed. # align_tag[c4d.ALIGNTOSPLINETAG_TANGENTIAL] = (path_type == "spline_oriented") doc.AddUndo(c4d.UNDOTYPE_NEW, align_tag) # Add undo for new tag self.log("Applied Align to Spline tag.") else: self.log("Warning: Failed to create Align to Spline tag.") # Handle other animation types (like wiggle) if needed here... # elif path_type == "wiggle": # # Apply wiggle expression or tag... (Requires specific implementation) # self.log("Info: Wiggle animation type not fully implemented in this version.") doc.EndUndo() # End undo block for animation changes c4d.EventAdd() # --- MODIFIED: Contextual Return --- actual_camera_name = camera.GetName() camera_guid = str(camera.GetGUID()) response_data = { "requested_name": requested_name, # Name used to find/create "actual_name": actual_camera_name, # Final name "guid": camera_guid, "camera_created": camera_created, # Was it created by this call? "path_type": path_type, "keyframe_count": keyframe_count, "frame_range_set": frame_range_set, # Frames actually keyframed "spline_path_guid": path_guid, # GUID of path spline if created # "properties_applied": applied_properties if camera_created else {}, # Properties set during creation } return {"camera_animation": response_data} # Keep original top-level key # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo ended self.log( f"[**ERROR**] Error animating camera '{requested_name}': {str(e)}\n{traceback.format_exc()}" ) # Clean up camera if created but not inserted if camera and not camera.GetDocument(): try: camera.Remove() except: pass return { "error": f"Failed to animate camera: {str(e)}", "traceback": traceback.format_exc(), } def _get_redshift_material_id(self): """Detect Redshift material ID by examining existing materials. This function scans the active document for materials with type IDs in the range typical for Redshift materials (over 1,000,000). Returns: A BaseMaterial with the detected Redshift material type or None if not found """ doc = c4d.documents.GetActiveDocument() # Look for existing Redshift materials to detect the proper ID for mat in doc.GetMaterials(): mat_type = mat.GetType() if mat_type >= 1000000: self.log( f"[C4D RS] Found existing Redshift material with type ID: {mat_type}" ) # Try to create a material with this ID try: rs_mat = c4d.BaseMaterial(mat_type) if rs_mat and rs_mat.GetType() == mat_type: self.log( f"[C4D RS] Successfully created Redshift material using detected ID: {mat_type}" ) return rs_mat except: pass # If Python scripting can create Redshift materials, try this method try: # Execute a Python script to create a Redshift material script = """ import c4d doc = c4d.documents.GetActiveDocument() # Try with known Redshift ID rs_mat = c4d.BaseMaterial(1036224) if rs_mat: rs_mat.SetName("TempRedshiftMaterial") doc.InsertMaterial(rs_mat) c4d.EventAdd() """ # Only try script-based approach if explicitly allowed if ( hasattr(c4d, "modules") and hasattr(c4d.modules, "net") and hasattr(c4d.modules.net, "Execute") ): # Execute in a controlled way that won't affect normal operation import tempfile, os script_path = None try: with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: f.write(script.encode("utf-8")) script_path = f.name # Try to execute this script self.execute_on_main_thread( lambda: c4d.modules.net.Execute(script_path) ) finally: # Always clean up the temp file if script_path and os.path.exists(script_path): try: os.unlink(script_path) except: pass # Now look for the material we created temp_mat = self._find_material_by_name(doc, "TempRedshiftMaterial") if temp_mat and temp_mat.GetType() >= 1000000: self.log( f"[C4D RS] Created Redshift material via script with type ID: {temp_mat.GetType()}" ) # Clean up the temporary material doc.RemoveMaterial(temp_mat) c4d.EventAdd() # Create a fresh material with this ID return c4d.BaseMaterial(temp_mat.GetType()) except Exception as e: self.log( f"[C4D RS] Script-based Redshift material creation failed: {str(e)}" ) # No Redshift materials found return None def _find_material_by_name(self, doc, name): """Find a material by name in the document. Args: doc: The active Cinema 4D document name: The name of the material to find Returns: The material if found, None otherwise """ if not name: self.log(f"[C4D] ## Warning ##: Empty material name provided") return None # Get all materials in the document materials = doc.GetMaterials() # First pass: exact match for mat in materials: if mat.GetName() == name: return mat # Second pass: case-insensitive match name_lower = name.lower() closest_match = None for mat in materials: if mat.GetName().lower() == name_lower: closest_match = mat self.log( f"[C4D] Found case-insensitive match for material '{name}': '{mat.GetName()}'" ) break if closest_match: return closest_match self.log(f"[C4D] Material not found: '{name}'") # If material not found, list available materials to aid debugging if materials: material_names = [mat.GetName() for mat in materials] self.log(f"[C4D] Available materials: {', '.join(material_names)}") return None def handle_validate_redshift_materials(self, command): """Validate Redshift node materials in the scene and fix issues when possible.""" import maxon warnings = [] fixes = [] doc = c4d.documents.GetActiveDocument() try: # Advanced Redshift detection diagnostics self.log(f"[C4D] DIAGNOSTIC: Cinema 4D version: {c4d.GetC4DVersion()}") self.log(f"[C4D] DIAGNOSTIC: Python version: {sys.version}") # Check for Redshift modules more comprehensively redshift_module_exists = hasattr(c4d, "modules") and hasattr( c4d.modules, "redshift" ) self.log( f"[C4D] DIAGNOSTIC: Redshift module exists: {redshift_module_exists}" ) if redshift_module_exists: redshift = c4d.modules.redshift self.log( f"[C4D] DIAGNOSTIC: Redshift module dir contents: {dir(redshift)}" ) # Check for common Redshift module attributes for attr in [ "Mmaterial", "MATERIAL_TYPE", "GetRSMaterialNodeSpace", ]: has_attr = hasattr(redshift, attr) self.log( f"[C4D] DIAGNOSTIC: Redshift module has '{attr}': {has_attr}" ) # Check if Redshift ID_REDSHIFT_MATERIAL constant exists has_rs_constant = hasattr(c4d, "ID_REDSHIFT_MATERIAL") self.log( f"[C4D] DIAGNOSTIC: c4d.ID_REDSHIFT_MATERIAL exists: {has_rs_constant}" ) if has_rs_constant: self.log( f"[C4D] DIAGNOSTIC: c4d.ID_REDSHIFT_MATERIAL value: {c4d.ID_REDSHIFT_MATERIAL}" ) # Check all installed plugins plugins = c4d.plugins.FilterPluginList(c4d.PLUGINTYPE_MATERIAL, True) self.log(f"[C4D] DIAGNOSTIC: Found {len(plugins)} material plugins") for plugin in plugins: plugin_name = plugin.GetName() plugin_id = plugin.GetID() self.log( f"[C4D] DIAGNOSTIC: Material plugin: {plugin_name} (ID: {plugin_id})" ) # Continue with normal validation # Get the Redshift node space ID redshift_ns = maxon.Id("com.redshift3d.redshift4c4d.class.nodespace") # Log all relevant Redshift material IDs for debugging self.log(f"[C4D] Standard material ID: {c4d.Mmaterial}") self.log( f"[C4D] Redshift material ID (c4d.ID_REDSHIFT_MATERIAL): {c4d.ID_REDSHIFT_MATERIAL}" ) # Check if Redshift module has its own material type constant if hasattr(c4d, "modules") and hasattr(c4d.modules, "redshift"): redshift = c4d.modules.redshift rs_material_id = getattr(redshift, "Mmaterial", None) if rs_material_id is not None: self.log(f"[C4D] Redshift module material ID: {rs_material_id}") rs_material_type = getattr(redshift, "MATERIAL_TYPE", None) if rs_material_type is not None: self.log(f"[C4D] Redshift MATERIAL_TYPE: {rs_material_type}") # Count of materials by type mat_stats = { "total": 0, "redshift": 0, "standard": 0, "fixed": 0, "issues": 0, "material_types": {}, } # Validate all materials in the document for mat in doc.GetMaterials(): mat_stats["total"] += 1 name = mat.GetName() # Track all material types encountered mat_type = mat.GetType() if mat_type not in mat_stats["material_types"]: mat_stats["material_types"][mat_type] = 1 else: mat_stats["material_types"][mat_type] += 1 # Check if it's a Redshift node material (should be c4d.ID_REDSHIFT_MATERIAL) is_rs_material = mat_type == c4d.ID_REDSHIFT_MATERIAL # Also check for alternative Redshift material type IDs if not is_rs_material and mat_type >= 1000000: # This is likely a Redshift material with a different ID self.log( f"[C4D] Found possible Redshift material with ID {mat_type}: {name}" ) is_rs_material = True if not is_rs_material: warnings.append( f"ℹ️ '{name}': Not a Redshift node material (type: {mat.GetType()})." ) mat_stats["standard"] += 1 # Auto-fix option: convert standard materials to Redshift if requested if command.get("auto_convert", False): try: # Create new Redshift material rs_mat = c4d.BaseMaterial(c4d.ID_REDSHIFT_MATERIAL) rs_mat.SetName(f"RS_{name}") # Copy basic properties color = mat[c4d.MATERIAL_COLOR_COLOR] # Set up default graph using CreateDefaultGraph try: rs_mat.CreateDefaultGraph(redshift_ns) except Exception as e: warnings.append( f"⚠️ Error creating default graph for '{name}': {str(e)}" ) # Continue anyway and try to work with what we have # Get the graph and root graph = rs_mat.GetGraph(redshift_ns) root = graph.GetRoot() # Find the Standard Surface output for node in graph.GetNodes(): if "StandardMaterial" in node.GetId(): # Set diffuse color try: node.SetParameter( maxon.nodes.ParameterID("base_color"), maxon.Color(color.x, color.y, color.z), maxon.PROPERTYFLAGS_NONE, ) except: pass break # Insert the new material doc.InsertMaterial(rs_mat) # Find and update texture tags if command.get("update_references", False): obj = doc.GetFirstObject() while obj: tag = obj.GetFirstTag() while tag: if tag.GetType() == c4d.Ttexture: if tag[c4d.TEXTURETAG_MATERIAL] == mat: tag[c4d.TEXTURETAG_MATERIAL] = rs_mat tag = tag.GetNext() obj = obj.GetNext() fixes.append( f"✅ Converted '{name}' to Redshift node material." ) mat_stats["fixed"] += 1 except Exception as e: warnings.append(f"❌ Failed to convert '{name}': {str(e)}") continue # For Redshift materials, continue with validation if is_rs_material: # It's a confirmed Redshift material mat_stats["redshift"] += 1 # Check if it's using the Redshift node space if ( hasattr(mat, "GetNodeMaterialSpace") and mat.GetNodeMaterialSpace() != redshift_ns ): warnings.append( f"⚠️ '{name}': Redshift material but not using correct node space." ) mat_stats["issues"] += 1 continue else: # Skip further validation for non-Redshift materials continue # Validate the node graph graph = mat.GetGraph(redshift_ns) if not graph: warnings.append(f"❌ '{name}': No node graph.") mat_stats["issues"] += 1 # Try to fix by creating a default graph if command.get("auto_fix", False): try: mat.CreateDefaultGraph(redshift_ns) fixes.append(f"✅ Created default graph for '{name}'.") mat_stats["fixed"] += 1 except Exception as e: warnings.append( f"❌ Could not create default graph for '{name}': {str(e)}" ) continue # Check the root node connections root = graph.GetRoot() if not root: warnings.append(f"❌ '{name}': No root node in graph.") mat_stats["issues"] += 1 continue # Check if we have inputs inputs = root.GetInputs() if not inputs or len(inputs) == 0: warnings.append(f"❌ '{name}': Root has no input ports.") mat_stats["issues"] += 1 continue # Check the output connection output_port = inputs[0] # First input is typically the main output output_node = output_port.GetDestination() if not output_node: warnings.append(f"⚠️ '{name}': Output not connected.") mat_stats["issues"] += 1 # Try to fix by creating a Standard Surface node if command.get("auto_fix", False): try: # Create Standard Surface node standard_surface = graph.CreateNode( maxon.nodes.IdAndVersion( "com.redshift3d.redshift4c4d.nodes.core.standardmaterial" ) ) # Connect to output graph.CreateConnection( standard_surface.GetOutputs()[0], # Surface output root.GetInputs()[0], # Surface input on root ) fixes.append(f"✅ Added Standard Surface node to '{name}'.") mat_stats["fixed"] += 1 except Exception as e: warnings.append( f"❌ Could not add Standard Surface to '{name}': {str(e)}" ) continue # Check that the output is connected to a Redshift Material node (Standard Surface, etc.) if ( "StandardMaterial" not in output_node.GetId() and "Material" not in output_node.GetId() ): warnings.append( f"❌ '{name}': Output not connected to a Redshift Material node." ) mat_stats["issues"] += 1 continue # Now check specific material inputs rs_mat_node = output_node # Check diffuse/base color base_color = None for input_port in rs_mat_node.GetInputs(): port_id = input_port.GetId() if "diffuse_color" in port_id or "base_color" in port_id: base_color = input_port break if base_color is None: warnings.append(f"⚠️ '{name}': No diffuse/base color input found.") mat_stats["issues"] += 1 continue if not base_color.GetDestination(): warnings.append( f"ℹ️ '{name}': Diffuse/base color input not connected." ) # This is not necessarily an issue, just informational else: source_node = base_color.GetDestination().GetNode() source_type = "unknown" # Identify the type of source if "ColorTexture" in source_node.GetId(): source_type = "texture" elif "Noise" in source_node.GetId(): source_type = "noise" elif "Checker" in source_node.GetId(): source_type = "checker" elif "Gradient" in source_node.GetId(): source_type = "gradient" elif "ColorConstant" in source_node.GetId(): source_type = "color" warnings.append( f"✅ '{name}': Diffuse/base color connected to {source_type} node." ) # Check for common issues in other ports # Detect if there's a fresnel node present has_fresnel = False for node in graph.GetNodes(): if "Fresnel" in node.GetId(): has_fresnel = True # Verify the Fresnel node has proper connections inputs_valid = True for input_port in node.GetInputs(): port_id = input_port.GetId() if "ior" in port_id and not input_port.GetDestination(): inputs_valid = False warnings.append( f"⚠️ '{name}': Fresnel node missing IOR input." ) mat_stats["issues"] += 1 outputs_valid = False for output_port in node.GetOutputs(): if output_port.GetSource(): outputs_valid = True break if not outputs_valid: warnings.append( f"⚠️ '{name}': Fresnel node has no output connections." ) mat_stats["issues"] += 1 if has_fresnel: warnings.append( f"ℹ️ '{name}': Contains Fresnel shader (check for potential issues)." ) # Summary stats summary = ( f"Material validation complete. Found {mat_stats['total']} materials: " + f"{mat_stats['redshift']} Redshift, {mat_stats['standard']} Standard, " + f"{mat_stats['issues']} with issues, {mat_stats['fixed']} fixed." ) # Update the document to apply any changes c4d.EventAdd() # Format material_types for better readability material_types_formatted = {} for type_id, count in mat_stats["material_types"].items(): if type_id == c4d.Mmaterial: name = "Standard Material" elif type_id == c4d.ID_REDSHIFT_MATERIAL: name = "Redshift Material (using c4d.ID_REDSHIFT_MATERIAL)" elif type_id == 1036224: name = "Redshift Material (1036224)" elif type_id >= 1000000: name = f"Possible Redshift Material ({type_id})" else: name = f"Unknown Type ({type_id})" material_types_formatted[name] = count # Replace the original dictionary with the formatted one mat_stats["material_types"] = material_types_formatted return { "status": "ok", "warnings": warnings, "fixes": fixes, "summary": summary, "stats": mat_stats, "ids": { "standard_material": c4d.Mmaterial, "redshift_material": c4d.ID_REDSHIFT_MATERIAL, }, } except Exception as e: return { "status": "error", "message": f"Error validating materials: {str(e)}", "warnings": warnings, } def handle_create_material(self, command): """Handle create_material command with context and proper NodeMaterial support for Redshift.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} requested_name = ( command.get("name") or command.get("material_name") or "New Material" ) color_list = command.get("color", [1.0, 1.0, 1.0]) # Default to white properties = command.get("properties", {}) material_type = command.get( "material_type", "standard" ).lower() # standard, redshift procedural = command.get( "procedural", False ) # Currently only affects Redshift in this example shader_type = command.get( "shader_type", "noise" ) # Used if procedural=True for Redshift # Safely parse color color = [1.0, 1.0, 1.0] if isinstance(color_list, list) and len(color_list) >= 3: try: color = [ max(0.0, min(1.0, float(c))) for c in color_list[:3] ] # Clamp 0-1 except (ValueError, TypeError): self.log(f"Warning: Invalid color data {color_list}") self.log( f"[C4D] Starting material creation: Name='{requested_name}', Type='{material_type}'" ) mat = None has_redshift = False redshift_plugin_id = None rs_mat_id_used = None # Store the ID actually used for RS material try: # Check for Redshift plugin plugins = c4d.plugins.FilterPluginList(c4d.PLUGINTYPE_MATERIAL, True) for plugin in plugins: if "redshift" in plugin.GetName().lower(): has_redshift = True redshift_plugin_id = plugin.GetID() self.log( f"[C4D] Found Redshift plugin: {plugin.GetName()} (ID: {plugin_id})" ) break # Found it if material_type == "redshift" and not has_redshift: self.log( "[C4D] ## Warning ##: Redshift requested but not found. Using standard material." ) material_type = "standard" doc.StartUndo() # Start undo block # Create material based on type if material_type == "redshift": self.log("[C4D] Attempting Redshift material creation...") try: # Determine the Redshift material ID to use rs_id = getattr( c4d, "ID_REDSHIFT_MATERIAL", redshift_plugin_id or 1036224 ) # Prefer constant, then plugin, then default rs_mat_id_used = rs_id # Store the ID we are trying self.log(f"[C4D] Using Redshift Material ID: {rs_id}") mat = c4d.BaseMaterial(rs_id) if not mat or mat.GetType() != rs_id: raise RuntimeError(f"Failed to create material with ID {rs_id}") mat.SetName(requested_name) self.log(f"[C4D] Base Redshift material created: '{mat.GetName()}'") # Setup node graph using maxon API (R20+) try: import maxon redshift_ns = maxon.Id( "com.redshift3d.redshift4c4d.class.nodespace" ) node_mat = c4d.NodeMaterial(mat) # Wrap in NodeMaterial if not node_mat: raise RuntimeError("Failed to create NodeMaterial wrapper") # Create default graph if it doesn't exist if not node_mat.HasSpace(redshift_ns): graph = node_mat.CreateDefaultGraph(redshift_ns) self.log("[C4D] Created default Redshift node graph") else: graph = node_mat.GetGraph(redshift_ns) self.log("[C4D] Using existing Redshift node graph") if not graph: raise RuntimeError( "Failed to get or create Redshift node graph" ) # Find StandardMaterial node and set base color standard_mat_node = None for node in graph.GetNodes(): if "StandardMaterial" in node.GetId(): standard_mat_node = node break if standard_mat_node: try: standard_mat_node.SetParameter( maxon.nodes.ParameterID("base_color"), maxon.Color(*color), maxon.PROPERTYFLAGS_NONE, ) self.log(f"[C4D] Set Redshift base_color to {color}") except Exception as e_node: self.log( f"Warning: Failed to set Redshift base_color: {e_node}" ) else: self.log( "Warning: Could not find StandardMaterial node in Redshift graph to set color." ) except ImportError: self.log( "Warning: 'maxon' module not found, cannot configure Redshift nodes." ) except Exception as e_node_setup: self.log( f"Warning: Error setting up Redshift node graph: {e_node_setup}" ) except Exception as e_rs: self.log( f"[**ERROR**] Redshift material creation failed: {e_rs}\n{traceback.format_exc()}. Falling back to standard." ) material_type = "standard" # Fallback flag mat = None # Reset mat so standard creation runs # Create a standard material if needed (or if RS failed) if material_type == "standard": self.log("[C4D] Creating standard material") mat = c4d.BaseMaterial(c4d.Mmaterial) if not mat: raise RuntimeError("Failed to create standard material") mat.SetName(requested_name) # Set standard material properties mat[c4d.MATERIAL_COLOR_COLOR] = c4d.Vector(*color) # Set color # Apply additional standard properties if provided if ( "specular" in properties and isinstance(properties["specular"], list) and len(properties["specular"]) >= 3 ): try: mat[c4d.MATERIAL_SPECULAR_COLOR] = c4d.Vector( *[float(s) for s in properties["specular"][:3]] ) except (ValueError, TypeError): self.log( f"Warning: Invalid specular color value {properties['specular']}" ) if "reflection" in properties: try: mat[c4d.MATERIAL_REFLECTION_BRIGHTNESS] = max( 0.0, float(properties["reflection"]) ) # Clamp >= 0 except (ValueError, TypeError): self.log( f"Warning: Invalid reflection value {properties['reflection']}" ) if not mat: # Final check if creation failed completely raise RuntimeError("Material creation failed for unknown reason.") # Insert material into document doc.InsertMaterial(mat) doc.AddUndo(c4d.UNDOTYPE_NEW, mat) # Add undo step doc.EndUndo() # End undo block c4d.EventAdd() # --- MODIFIED: Contextual Return --- actual_name = mat.GetName() mat_type_id = mat.GetType() final_material_type = ( "redshift" if mat_type_id == rs_mat_id_used else "standard" ) # Determine final type based on actual ID # Get final color (might differ if RS nodes failed) final_color = color # Default to requested if final_material_type == "standard": try: final_color = [ mat[c4d.MATERIAL_COLOR_COLOR].x, mat[c4d.MATERIAL_COLOR_COLOR].y, mat[c4d.MATERIAL_COLOR_COLOR].z, ] except: pass # Keep requested color if read fails self.log( f"[C4D] Material created successfully: Name='{actual_name}', Type='{final_material_type}', ID={mat_type_id}" ) # Note: Materials don't have GUIDs in the same way as objects, so we don't register them. # We return info based on the final state. return { "material": { "requested_name": requested_name, "actual_name": actual_name, "type": final_material_type, # Report actual type created "color_set": final_color, # Report the final color state if possible "type_id": mat_type_id, "redshift_available": has_redshift, # Add any other relevant context about properties set } } # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo ended error_msg = f"Failed to create material '{requested_name}': {str(e)}" self.log(f"[**ERROR**] {error_msg}\n{traceback.format_exc()}") # Clean up material if created but not inserted if mat and not mat.GetDocument(): try: mat.Remove() except: pass return {"error": error_msg, "traceback": traceback.format_exc()} def handle_render_frame( self, command ): # Renamed from handle_render_to_file to match command key """Render the current frame to a file, using adapted core logic.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} output_path = command.get("output_path") width = int(command.get("width", 640)) height = int(command.get("height", 360)) # Frame handling - default to current frame if not specified frame = command.get("frame") if frame is None: frame = doc.GetTime().GetFrame(doc.GetFps()) else: try: frame = int(frame) except (ValueError, TypeError): self.log(f"Warning: Invalid frame value '{frame}', using current.") frame = doc.GetTime().GetFrame(doc.GetFps()) self.log( f"[RENDER FRAME] Request: frame={frame}, size={width}x{height}, path={output_path}" ) # Ensure output path is valid and directory exists if not output_path: doc_name = os.path.splitext(doc.GetDocumentName() or "Untitled")[0] fallback_dir = doc.GetDocumentPath() or os.path.join( os.path.expanduser("~"), "Desktop" ) # Fallback to desktop output_path = os.path.join( fallback_dir, f"{doc_name}_render_{frame:04d}.png" ) self.log( f"[RENDER FRAME] No output path provided, using fallback: {output_path}" ) else: output_path = os.path.normpath(os.path.expanduser(output_path)) output_dir = os.path.dirname(output_path) try: os.makedirs(output_dir, exist_ok=True) except OSError as e: return {"error": f"Cannot create output directory '{output_dir}': {e}"} # Determine format from extension (Default to PNG) ext = os.path.splitext(output_path)[1].lower() format_map = { ".png": c4d.FILTER_PNG, ".jpg": c4d.FILTER_JPG, ".jpeg": c4d.FILTER_JPG, ".tif": c4d.FILTER_TIF, ".tiff": c4d.FILTER_TIF, } format_id = format_map.get(ext, c4d.FILTER_PNG) if format_id == c4d.FILTER_PNG and ext not in format_map: output_path = os.path.splitext(output_path)[0] + ".png" format_id = c4d.FILTER_PNG self.log( f"Warning: Unsupported output extension '{ext}', defaulting to PNG: {output_path}" ) # --- Execute render task on main thread --- def render_task(): bmp = None render_duration = 0.0 original_rd = None # Keep track of original RD rd_clone = None # Keep track of clone RD temp_rd_inserted = False try: # --- Start Core Logic Adaptation --- if not doc: return {"error": "No active document (in render_task)"} active_draw = doc.GetActiveBaseDraw() if not active_draw: return {"error": "No active BaseDraw (in render_task)"} active_camera = ( active_draw.GetSceneCamera(doc) or active_draw.GetEditorCamera() ) if not active_camera: return {"error": "No active camera (in render_task)"} original_rd = doc.GetActiveRenderData() if not original_rd: return {"error": "No active RenderData (in render_task)"} rd_clone = original_rd.GetClone(c4d.COPYFLAGS_NONE) if not rd_clone: return {"error": "RenderData clone failed (in render_task)"} settings = rd_clone.GetDataInstance() if not settings: raise RuntimeError("Failed to get settings instance") settings[c4d.RDATA_XRES] = float(width) settings[c4d.RDATA_YRES] = float(height) settings[c4d.RDATA_FRAMESEQUENCE] = c4d.RDATA_FRAMESEQUENCE_CURRENTFRAME settings[c4d.RDATA_SAVEIMAGE] = False # Render to bitmap, not auto-save doc.InsertRenderData(rd_clone) temp_rd_inserted = True doc.SetActiveRenderData(rd_clone) target_time = c4d.BaseTime(frame, doc.GetFps()) doc.SetTime(target_time) # --- FIXED ExecutePasses Call --- doc.ExecutePasses( None, True, True, True, c4d.BUILDFLAGS_NONE ) # Use None instead of active_draw # --- END FIXED --- bmp = c4d.bitmaps.BaseBitmap() # Use BaseBitmap if ( not bmp or bmp.Init(int(width), int(height), 24) != c4d.IMAGERESULT_OK ): # Use int() for dimensions raise MemoryError(f"Bitmap Init failed ({width}x{height})") render_flags = ( c4d.RENDERFLAGS_EXTERNAL | c4d.RENDERFLAGS_SHOWERRORS | c4d.RENDERFLAGS_NODOCUMENTCLONE | 0x00040000 ) start_time = time.time() result_code = c4d.documents.RenderDocument( doc, settings, bmp, render_flags, None ) render_duration = time.time() - start_time if result_code != c4d.RENDERRESULT_OK: err_str = self._render_code_to_str(result_code) last_c4d_err = c4d.GetLastError() if last_c4d_err: err_str += f" (GetLastError: {last_c4d_err})" raise RuntimeError(f"RenderDocument failed: {err_str}") # --- End Core Logic Adaptation --- # Save the resulting bitmap to file self.log( f"[RENDER FRAME] Saving bitmap to: {output_path} (Format ID: {format_id})" ) save_result = bmp.Save(output_path, format_id) if save_result == c4d.IMAGERESULT_OK: self.log(f"[RENDER FRAME] Bitmap saved successfully.") return { "success": True, "output_path": output_path, "width": width, "height": height, "frame": frame, "render_time": render_duration, "file_exists": os.path.exists(output_path), } else: return { "error": f"Failed to save bitmap (Error code: {save_result})" } except Exception as e_render: tb = traceback.format_exc() self.log( f"[**ERROR**][RENDER FRAME] Error during render task: {e_render}\n{tb}" ) return { "error": f"Exception during render/save: {str(e_render)}", "traceback": tb, } finally: # Cleanup render data clone if temp_rd_inserted and original_rd: try: doc.SetActiveRenderData(original_rd) if rd_clone: rd_clone.Remove() except Exception as e_cleanup: self.log(f"Warning: Error during RD cleanup: {e_cleanup}") # Cleanup bitmap if bmp: try: bmp.FlushAll() except: pass c4d.EventAdd() # Execute the task on the main thread response = self.execute_on_main_thread(render_task, _timeout=180) # Structure the final response for the tool if response and response.get("success"): return { "render_info": response } # Return nested structure expected by server tool else: # Ensure error structure is consistent if render_task itself returns an error dict if isinstance(response, dict) and "error" in response: return response # Handle cases where execute_on_main_thread returned an error (like timeout) elif isinstance(response, dict) and "error" in response: return response else: # Fallback for unexpected scenarios return {"error": "Unknown error during render frame execution."} def handle_apply_shader(self, command): """Handle apply_shader command with improved Redshift/Fresnel support and context.""" doc = c4d.documents.GetActiveDocument() if not doc: return {"error": "No active document"} material_name = command.get("material_name", "") # --- MODIFIED: Identify target object --- identifier = None use_guid = False object_specified = False if command.get("guid"): # Check for GUID first identifier = command.get("guid") use_guid = True object_specified = True self.log(f"[APPLY SHADER] Using GUID identifier for object: '{identifier}'") elif command.get("object_name"): identifier = command.get("object_name") use_guid = False object_specified = True self.log(f"[APPLY SHADER] Using Name identifier for object: '{identifier}'") # --- END MODIFIED --- shader_type = command.get("shader_type", "noise").lower() channel = command.get("channel", "color").lower() parameters = command.get("parameters", {}) self.log( f"[APPLY SHADER] Request: Shader='{shader_type}', Channel='{channel}', Material='{material_name}', Object='{identifier}'" ) mat = None created_new_material = False obj_to_apply = None try: doc.StartUndo() # Start undo block # Find or create material if material_name: mat = self._find_material_by_name(doc, material_name) if mat is None: default_mat_name = ( material_name if material_name else f"{shader_type.capitalize()} Material" ) mat = c4d.BaseMaterial(c4d.Mmaterial) # Create standard by default if not mat: raise RuntimeError("Failed to create new material") mat.SetName(default_mat_name) doc.InsertMaterial(mat) doc.AddUndo(c4d.UNDOTYPE_NEW, mat) created_new_material = True material_name = mat.GetName() # Use actual name self.log(f"[APPLY SHADER] Created new material: '{material_name}'") # Find object if specified if object_specified: obj_to_apply = self.find_object_by_name( doc, identifier, use_guid=use_guid ) if obj_to_apply is None: search_type = "GUID" if use_guid else "Name" self.log( f"Warning: Object '{identifier}' (searched by {search_type}) not found for shader application." ) # Don't error out, just won't apply tag later # Determine if material is Redshift is_redshift_material = False rs_mat_id = getattr( c4d, "ID_REDSHIFT_MATERIAL", 1036224 ) # Get RS ID safely if mat.GetType() == rs_mat_id: is_redshift_material = True elif mat.GetType() >= 1000000: # General check for other RS types is_redshift_material = True self.log( f"Info: Material '{material_name}' has high ID ({mat.GetType()}), treating as Redshift." ) if is_redshift_material: self.log( f"[APPLY SHADER] Applying shader to Redshift material '{material_name}'..." ) # --- Redshift Node Graph Logic --- try: import maxon redshift_ns = maxon.Id( "com.redshift3d.redshift4c4d.class.nodespace" ) node_mat = c4d.NodeMaterial(mat) if node_mat and node_mat.HasSpace(redshift_ns): graph = node_mat.GetGraph(redshift_ns) if graph: with graph.BeginTransaction() as transaction: # Find output node... (Simplified for brevity - assumes StandardMaterial exists) material_output = None for node in graph.GetNodes(): if "StandardMaterial" in node.GetId(): material_output = node break if material_output: # Create shader node... shader_node = None shader_node_id_str = "" if shader_type == "noise": shader_node_id_str = "com.redshift3d.redshift4c4d.nodes.core.texturesampler" elif shader_type == "fresnel": shader_node_id_str = "com.redshift3d.redshift4c4d.nodes.core.fresnel" # Add more shader types here... if shader_node_id_str: shader_node = graph.AddChild( maxon.Id(), maxon.Id(shader_node_id_str) ) if ( shader_node and shader_type == "noise" ): # Configure noise specific shader_node.SetParameter( maxon.nodes.ParameterID("tex0_tex"), 4, maxon.PROPERTYFLAGS_NONE, ) # 4=Noise if "scale" in parameters: shader_node.SetParameter( maxon.nodes.ParameterID( "noise_scale" ), float(parameters["scale"]), maxon.PROPERTYFLAGS_NONE, ) # Connect shader node... if shader_node: # Find target port... (Simplified) target_port_id_str = ( "base_color" if channel == "color" else "refl_color" ) # Example mapping target_port = material_output.GetInputs().Find( maxon.Id(target_port_id_str) ) # Find source port... (Simplified) source_port_id_str = ( "outcolor" if shader_type != "fresnel" else "out" ) source_port = shader_node.GetOutputs().Find( maxon.Id(source_port_id_str) ) if target_port and source_port: graph.CreateConnection( source_port, target_port ) self.log( f"Connected RS {shader_type} node to {channel}" ) else: self.log( "Warning: Could not find source/target ports for RS shader connection." ) else: self.log( f"Warning: Failed to create RS {shader_type} node." ) else: self.log( "Warning: Could not find RS StandardMaterial output node." ) transaction.Commit() else: self.log("Warning: Could not get RS node graph.") else: self.log( "Warning: Material is not a Redshift Node Material or lacks RS space." ) except ImportError: self.log("Warning: 'maxon' module not found, cannot edit RS nodes.") except Exception as e_rs: self.log(f"Error applying shader to RS material: {e_rs}") # Fallthrough to standard shader application is NOT intended here. If it's RS, we try nodes. else: # --- Standard Shader Logic (from original) --- self.log( f"[APPLY SHADER] Applying shader to Standard material '{material_name}'..." ) shader_types = { "noise": 5832, "gradient": 5825, "fresnel": 5837, "layer": 5685, "checkerboard": 5831, } channel_map = { "color": c4d.MATERIAL_COLOR_SHADER, "luminance": c4d.MATERIAL_LUMINANCE_SHADER, "transparency": c4d.MATERIAL_TRANSPARENCY_SHADER, "reflection": c4d.MATERIAL_REFLECTION_SHADER, "bump": c4d.MATERIAL_BUMP_SHADER, } # Added bump shader_type_id = shader_types.get(shader_type, 5832) channel_id = channel_map.get(channel) if channel_id is None: raise ValueError(f"Unsupported standard channel: {channel}") shader = c4d.BaseShader(shader_type_id) if shader is None: raise RuntimeError( f"Failed to create standard {shader_type} shader" ) # Apply parameters (example for noise) if shader_type == "noise" and hasattr(c4d, "SLA_NOISE_SCALE"): if "scale" in parameters: shader[c4d.SLA_NOISE_SCALE] = float( parameters.get("scale", 1.0) ) if "octaves" in parameters: shader[c4d.SLA_NOISE_OCTAVES] = int( parameters.get("octaves", 3) ) # Add more parameter settings for other standard shader types here... mat[channel_id] = shader # Enable the channel enable_map = { "color": c4d.MATERIAL_USE_COLOR, "luminance": c4d.MATERIAL_USE_LUMINANCE, "transparency": c4d.MATERIAL_USE_TRANSPARENCY, "reflection": c4d.MATERIAL_USE_REFLECTION, "bump": c4d.MATERIAL_USE_BUMP, } if channel in enable_map: try: mat[enable_map[channel]] = True except AttributeError: self.log( f"Warning: Could not find enable parameter for channel '{channel}'" ) mat.Update(True, True) doc.AddUndo(c4d.UNDOTYPE_CHANGE, mat) # Add undo for material change # Apply material to object if found applied_to_name = "None" applied_to_guid = None if obj_to_apply: try: # Check if object already has a texture tag for this material existing_tag = None for tag in obj_to_apply.GetTags(): if tag.GetType() == c4d.Ttexture and tag.GetMaterial() == mat: existing_tag = tag self.log( f"Found existing texture tag for material '{material_name}' on '{obj_to_apply.GetName()}'" ) break if not existing_tag: tag = obj_to_apply.MakeTag( c4d.Ttexture ) # Use MakeTag for safer insertion if tag: tag.SetMaterial(mat) doc.AddUndo(c4d.UNDOTYPE_NEW, tag) applied_to_name = obj_to_apply.GetName() applied_to_guid = str(obj_to_apply.GetGUID()) self.log( f"[APPLY SHADER] Applied material '{material_name}' to object '{applied_to_name}'" ) else: self.log( f"Warning: Failed to create texture tag on '{obj_to_apply.GetName()}'" ) else: # Material already applied via existing tag applied_to_name = obj_to_apply.GetName() applied_to_guid = str(obj_to_apply.GetGUID()) self.log( f"Material '{material_name}' was already applied to object '{applied_to_name}'" ) except Exception as e_tag: self.log( f"[**ERROR**] Error applying material tag to '{obj_to_apply.GetName()}': {str(e_tag)}" ) doc.EndUndo() # End undo block c4d.EventAdd() # --- MODIFIED: Contextual Return --- return { "shader_application": { # Changed key for clarity "material_name": material_name, "material_type_id": mat.GetType(), "shader_type": shader_type, "channel": channel, "applied_to_object_name": applied_to_name, # Name or "None" "applied_to_object_guid": applied_to_guid, # GUID or None "created_new_material": created_new_material, "is_redshift_material": is_redshift_material, } } # --- END MODIFIED --- except Exception as e: doc.EndUndo() # Ensure undo ended self.log( f"[**ERROR**] Error applying shader: {str(e)}\n{traceback.format_exc()}" ) return { "error": f"Failed to apply shader: {str(e)}", "traceback": traceback.format_exc(), } class SocketServerDialog(gui.GeDialog): """GUI Dialog to control the server and display logs.""" def __init__(self): super(SocketServerDialog, self).__init__() self.server = None self.msg_queue = queue.Queue() # Thread-safe queue self.SetTimer(100) # Update UI at 10 Hz def CreateLayout(self): self.SetTitle("Socket Server Control") self.status_text = self.AddStaticText( 1002, c4d.BFH_SCALEFIT, name="Server: Offline" ) self.GroupBegin(1010, c4d.BFH_SCALEFIT, 2, 1) self.AddButton(1011, c4d.BFH_SCALE, name="Start Server") self.AddButton(1012, c4d.BFH_SCALE, name="Stop Server") self.GroupEnd() self.log_box = self.AddMultiLineEditText( 1004, c4d.BFH_SCALEFIT, initw=400, inith=250, style=c4d.DR_MULTILINE_READONLY, ) self.Enable(1012, False) # Disable "Stop" button initially return True def CoreMessage(self, id, msg): """Handles UI updates and main thread execution triggered by SpecialEventAdd().""" if id == PLUGIN_ID: try: # Process all pending messages in the queue while not self.msg_queue.empty(): try: # Get next message from queue with timeout to avoid potential deadlocks msg_type, msg_value = self.msg_queue.get(timeout=0.1) # Process based on message type if msg_type == "STATUS": self.UpdateStatusText(msg_value) elif msg_type == "LOG": self.AppendLog(msg_value) elif msg_type == "EXEC": # Execute function on main thread if callable(msg_value): try: msg_value() except Exception as e: error_msg = f"[**ERROR**] Error in main thread execution: {str(e)}" self.AppendLog(error_msg) print( error_msg ) # Also print to console for debugging else: self.AppendLog( f"[C4D] ## Warning ##: Non-callable value received: {type(msg_value)}" ) else: self.AppendLog( f"[C4D] ## Warning ##: Unknown message type: {msg_type}" ) except queue.Empty: # Queue timeout - break the loop to prevent blocking break except Exception as e: # Handle any other exceptions during message processing error_msg = f"[**ERROR**] Error processing message: {str(e)}" self.AppendLog(error_msg) print(error_msg) # Also print to console for debugging except Exception as e: # Catch all exceptions to prevent Cinema 4D from crashing error_msg = f"[C4D] Critical error in message processing: {str(e)}" print(error_msg) # Print to console as UI might be unstable try: self.AppendLog(error_msg) except: pass # Ignore if we can't even log to UI return True def Timer(self, msg): """Periodic UI update in case SpecialEventAdd() missed something.""" if self.server: if not self.server.running: # Detect unexpected crashes self.UpdateStatusText("Offline") self.Enable(1011, True) self.Enable(1012, False) return True def UpdateStatusText(self, status): """Update server status UI.""" self.SetString(1002, f"Server: {status}") self.Enable(1011, status == "Offline") self.Enable(1012, status == "Online") def AppendLog(self, message): """Append log messages to UI.""" existing_text = self.GetString(1004) new_text = (existing_text + "\n" + message).strip() self.SetString(1004, new_text) def Command(self, id, msg): if id == 1011: # Start Server button self.StartServer() return True elif id == 1012: # Stop Server button self.StopServer() return True return False def StartServer(self): """Start the socket server thread.""" if not self.server: self.server = C4DSocketServer(msg_queue=self.msg_queue) self.server.start() self.Enable(1011, False) self.Enable(1012, True) def StopServer(self): """Stop the socket server.""" if self.server: self.server.stop() self.server = None self.Enable(1011, True) self.Enable(1012, False) class SocketServerPlugin(c4d.plugins.CommandData): """Cinema 4D Plugin Wrapper""" PLUGIN_ID = 1057843 PLUGIN_NAME = "Socket Server Plugin" def __init__(self): self.dialog = None def Execute(self, doc): if self.dialog is None: self.dialog = SocketServerDialog() return self.dialog.Open( dlgtype=c4d.DLG_TYPE_ASYNC, pluginid=self.PLUGIN_ID, defaultw=400, defaulth=300, ) def GetState(self, doc): return c4d.CMD_ENABLED if __name__ == "__main__": c4d.plugins.RegisterCommandPlugin( SocketServerPlugin.PLUGIN_ID, SocketServerPlugin.PLUGIN_NAME, 0, None, None, SocketServerPlugin(), )

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/ttiimmaacc/cinema4d-mcp'

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