Skip to main content
Glama
freecad_client.py28 kB
#!/usr/bin/env python3 """ FreeCAD Client A client for communicating with FreeCAD using the unified connection interface. This client can work with the socket-based server, CLI bridge, or mock implementation. """ import argparse import json import os import socket import sys import textwrap from typing import Any, Dict, Optional import requests # Import the FreeCADConnection class try: from freecad_connection_manager import FreeCADConnection UNIFIED_CONNECTION_AVAILABLE = True except ImportError: UNIFIED_CONNECTION_AVAILABLE = False print("Warning: FreeCADConnection not found. Using legacy socket connection mode.") print( "You may need to install the freecad_connection_manager.py module in the same directory." ) # Define Gemini API constants GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro-preview:generateContent" class FreeCADClient: """Client for communicating with FreeCAD""" def __init__( self, host="localhost", port=12345, timeout=10.0, freecad_path="freecad", connection_method=None, auto_connect=True, ): """ Initialize the client Args: host: Server hostname for socket connection port: Server port for socket connection timeout: Connection timeout in seconds freecad_path: Path to FreeCAD executable connection_method: Preferred connection method (server, bridge, mock) auto_connect: Whether to connect automatically """ self.host = host self.port = port self.timeout = timeout self.freecad_path = freecad_path self.connection_method = connection_method self._connection = None self._legacy_mode = not UNIFIED_CONNECTION_AVAILABLE if auto_connect: self.connect() def connect(self) -> bool: """ Connect to FreeCAD Returns: bool: True if successfully connected """ if self._legacy_mode: # Legacy mode - no connection is maintained, just success status try: result = self.ping() return result.get("pong", False) except (socket.error, json.JSONDecodeError): return False else: # Use unified connection try: self._connection = FreeCADConnection( host=self.host, port=self.port, freecad_path=self.freecad_path, prefer_method=self.connection_method, auto_connect=True, ) # Check if connection is valid if self._connection.is_connected(): conn_type = self._connection.get_connection_type() print(f"Connected to FreeCAD using {conn_type} method") return True else: print("Failed to establish FreeCAD connection") return False except Exception as e: print(f"Error establishing connection: {e}") return False def is_connected(self) -> bool: """ Check if connected to FreeCAD Returns: bool: True if connected """ if self._legacy_mode: # In legacy mode, we always try to connect on demand try: result = self.ping() return result.get("pong", False) except (socket.error, json.JSONDecodeError): return False else: return self._connection and self._connection.is_connected() def get_connection_type(self) -> Optional[str]: """ Get the current connection type Returns: str: Connection type (server, bridge, or mock) """ if self._legacy_mode: return "legacy_socket" return self._connection.get_connection_type() if self._connection else None def send_command(self, command_type, params=None): """ Send a command to FreeCAD Args: command_type: Command type params: Command parameters Returns: dict: Command response """ if params is None: params = {} if self._legacy_mode: # Legacy socket-only implementation command = {"type": command_type, "params": params} # Create socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(self.timeout) try: # Connect to server sock.connect((self.host, self.port)) # Send command sock.sendall(json.dumps(command).encode() + b"\n") # Receive response response = sock.recv(8192).decode() # Parse response result = json.loads(response) # Check for FreeCADGui attribute errors and provide more helpful messages if ( "error" in result and "module 'FreeCADGui' has no attribute" in result["error"] ): # Add helpful context to the error message result[ "error" ] += "\nThis is likely because the server is running without a GUI environment. " result[ "error" ] += "Use the '--connect' flag with the server to connect to a running FreeCAD instance." return result except socket.timeout: return {"error": f"Connection timed out after {self.timeout} seconds"} except ConnectionRefusedError: return { "error": f"Connection refused. Is the FreeCAD server running on {self.host}:{self.port}?" } except Exception as e: return {"error": f"Error communicating with FreeCAD server: {str(e)}"} finally: sock.close() else: # Use unified connection if not self._connection: return {"error": "Not connected to FreeCAD"} result = self._connection.execute_command(command_type, params) # Check for FreeCADGui attribute errors and provide more helpful messages if ( "error" in result and "module 'FreeCADGui' has no attribute" in result["error"] ): # Add helpful context to the error message result[ "error" ] += "\nThis is likely because the server is running without a GUI environment. " result[ "error" ] += "Use the '--connect' flag with the server to connect to a running FreeCAD instance." return result def ping(self): """Ping the server to check if it's responsive""" return self.send_command("ping") def get_version(self): """Get FreeCAD version information""" return self.send_command("get_version") def get_model_info(self, document=None): """Get information about the current model""" params = {} if document: params["document"] = document return self.send_command("get_model_info", params) def create_document(self, name="Unnamed"): """ Create a new document Args: name: Document name Returns: dict: Response with document info """ return self.send_command("create_document", {"name": name}) def close_document(self, name=None): """Close a document""" params = {} if name: params["name"] = name return self.send_command("close_document", params) def create_box( self, length=10.0, width=10.0, height=10.0, document=None, name=None ): """Create a box primitive""" params = { "type": "box", "properties": {"length": length, "width": width, "height": height}, } if document: params["document"] = document if name: params["name"] = name return self.send_command("create_object", params) def create_cylinder(self, radius=5.0, height=10.0, document=None, name=None): """Create a cylinder primitive""" params = { "type": "cylinder", "properties": {"radius": radius, "height": height}, } if document: params["document"] = document if name: params["name"] = name return self.send_command("create_object", params) def create_sphere(self, radius=5.0, document=None, name=None): """Create a sphere primitive""" params = {"type": "sphere", "properties": {"radius": radius}} if document: params["document"] = document if name: params["name"] = name return self.send_command("create_object", params) def modify_object(self, object_name, properties, document=None): """Modify an existing object""" params = {"object": object_name, "properties": properties} if document: params["document"] = document return self.send_command("modify_object", params) def delete_object(self, object_name, document=None): """Delete an object""" params = {"object": object_name} if document: params["document"] = document return self.send_command("delete_object", params) def execute_script(self, script): """Execute a Python script in FreeCAD context""" return self.send_command("execute_script", {"script": script}) def measure_distance(self, from_point, to_point): """Measure distance between two points""" params = {"from": from_point, "to": to_point} return self.send_command("measure_distance", params) def export_document(self, file_path, file_type="step", document=None): """Export document to a file""" params = {"path": file_path, "type": file_type} if document: params["document"] = document return self.send_command("export_document", params) def close(self): """Close the connection if using unified connection""" if not self._legacy_mode and self._connection: self._connection.close() self._connection = None def call_gemini_api(api_key: str, prompt: str) -> Dict[str, Any]: """ Call the Google Gemini API with the given prompt. Args: api_key: Google Gemini API key prompt: User prompt text Returns: Dict containing the API response """ headers = { "Content-Type": "application/json", } data = { "contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"temperature": 0.2, "topP": 0.8, "topK": 40}, } response = requests.post( f"{GEMINI_API_URL}?key={api_key}", headers=headers, json=data ) if response.status_code != 200: print(f"Error calling Gemini API: {response.status_code}") print(response.text) return {"error": f"API error: {response.status_code}"} return response.json() def extract_freecad_commands(gemini_response: Dict[str, Any]) -> Dict[str, Any]: """ Extract FreeCAD commands from Gemini's response. Args: gemini_response: Response from Gemini API Returns: Dict with parsed commands or error """ try: # Extract the text from Gemini's response if "candidates" not in gemini_response or not gemini_response["candidates"]: return {"error": "No response from Gemini API"} text = gemini_response["candidates"][0]["content"]["parts"][0]["text"] # Look for code blocks that might contain commands if "```" in text: # Extract code between triple backticks code_blocks = text.split("```") # Code blocks are at odd indices (1, 3, 5, etc.) commands_text = "\n".join( [code_blocks[i] for i in range(1, len(code_blocks), 2)] ) else: # If no code blocks, use the whole text commands_text = text # Parse and identify the command type and parameters lines = commands_text.strip().split("\n") command_parts = {} # Simple parsing - look for keywords that might indicate commands for line in lines: line = line.strip() if not line or line.startswith("#"): continue # Try to determine the command type if any( keyword in line.lower() for keyword in ["create_document", "new document"] ): command_parts["type"] = "create-document" # Try to extract document name if "name" not in command_parts and ":" in line: name = line.split(":", 1)[1].strip().strip("\"'") command_parts["name"] = name elif any( keyword in line.lower() for keyword in ["create_box", "box", "cube"] ): command_parts["type"] = "create-box" # Look for dimensions if "length" in line.lower(): try: command_parts["length"] = float(line.split("=")[1].strip()) except: pass if "width" in line.lower(): try: command_parts["width"] = float(line.split("=")[1].strip()) except: pass if "height" in line.lower(): try: command_parts["height"] = float(line.split("=")[1].strip()) except: pass elif any( keyword in line.lower() for keyword in ["create_cylinder", "cylinder"] ): command_parts["type"] = "create-cylinder" if "radius" in line.lower(): try: command_parts["radius"] = float(line.split("=")[1].strip()) except: pass if "height" in line.lower(): try: command_parts["height"] = float(line.split("=")[1].strip()) except: pass elif any( keyword in line.lower() for keyword in ["create_sphere", "sphere"] ): command_parts["type"] = "create-sphere" if "radius" in line.lower(): try: command_parts["radius"] = float(line.split("=")[1].strip()) except: pass elif any(keyword in line.lower() for keyword in ["export", "save"]): command_parts["type"] = "export-document" if "path" in line.lower() or "file" in line.lower(): try: path = line.split("=")[1].strip().strip("\"'") command_parts["path"] = path except: pass if "format" in line.lower(): try: format_type = line.split("=")[1].strip().strip("\"'") command_parts["format"] = format_type except: pass # Look for document reference in any command if "document" in line.lower() and "document" not in command_parts: try: doc = line.split("=")[1].strip().strip("\"'") command_parts["document"] = doc except: pass if not command_parts.get("type"): return { "error": "Could not determine a clear FreeCAD command from the response", "original_response": text, } return command_parts except Exception as e: return {"error": f"Error parsing Gemini response: {str(e)}"} def interactive_prompt_mode(client, api_key): """ Run an interactive prompt mode using Gemini to translate natural language to FreeCAD commands. Args: client: FreeCAD client instance api_key: Gemini API key """ print("\n==== FreeCAD Prompt Mode with Gemini 2.5 Pro ====") print("Type 'exit' or 'quit' to end the session") print("Example prompts:") print(" - Create a new document called MyProject") print(" - Make a box with length 20, width 15, and height 10") print(" - Create a sphere with radius 25") print(" - Export the current model to 'my_model.step'\n") while True: try: user_input = input("FreeCAD > ") # Check for exit command if user_input.lower() in ["exit", "quit", "q"]: print("Exiting prompt mode.") break # Skip empty inputs if not user_input.strip(): continue # Call Gemini API print("Thinking...") gemini_response = call_gemini_api( api_key, f""" You are a FreeCAD command translator. Convert the following natural language request into a specific FreeCAD command. Only output the command name and parameters, nothing else. Available commands are: - create-document <name> - create-box --length X --width Y --height Z --document DOC - create-cylinder --radius X --height Y --document DOC - create-sphere --radius X --document DOC - export-document --path PATH --format FORMAT --document DOC USER REQUEST: {user_input} """, ) if "error" in gemini_response: print(f"Error: {gemini_response['error']}") continue # Extract FreeCAD command from Gemini's response command = extract_freecad_commands(gemini_response) if "error" in command: print(f"Error: {command['error']}") if "original_response" in command: print("\nOriginal Gemini response:") print(textwrap.fill(command["original_response"], width=80)) continue # Execute the command based on type cmd_type = command.get("type") if cmd_type == "create-document": doc_name = command.get("name", "Unnamed") print(f"Creating document: {doc_name}") result = client.create_document(doc_name) elif cmd_type == "create-box": length = command.get("length", 10.0) width = command.get("width", 10.0) height = command.get("height", 10.0) document = command.get("document") print(f"Creating box: {length}x{width}x{height}") result = client.create_box( length=length, width=width, height=height, document=document ) elif cmd_type == "create-cylinder": radius = command.get("radius", 5.0) height = command.get("height", 10.0) document = command.get("document") print(f"Creating cylinder: radius={radius}, height={height}") result = client.create_cylinder( radius=radius, height=height, document=document ) elif cmd_type == "create-sphere": radius = command.get("radius", 5.0) document = command.get("document") print(f"Creating sphere: radius={radius}") result = client.create_sphere(radius=radius, document=document) elif cmd_type == "export-document": path = command.get("path", "model.step") format_type = command.get("format", "step") document = command.get("document") print(f"Exporting document to: {path}") result = client.export_document( file_path=path, file_type=format_type, document=document ) else: print(f"Unknown command type: {cmd_type}") continue # Display the result if "error" in result: print(f"Command failed: {result['error']}") else: print("Command executed successfully") except Exception as e: print(f"Error: {str(e)}") def main(): """Main function for CLI usage""" parser = argparse.ArgumentParser(description="FreeCAD Client") parser.add_argument("--host", default="localhost", help="Server host") parser.add_argument("--port", type=int, default=12345, help="Server port") parser.add_argument( "--timeout", type=float, default=10.0, help="Connection timeout" ) parser.add_argument( "--freecad", default="freecad", help="Path to FreeCAD executable" ) parser.add_argument( "--connect", action="store_true", help="Connect to running FreeCAD instance", ) parser.add_argument( "--auto", action="store_true", help="Automatically choose connection method", ) # Add subcommands subparsers = parser.add_subparsers(dest="command", help="Command to execute") # Version command subparsers.add_parser("version", help="Get FreeCAD version") # Create document command doc_parser = subparsers.add_parser("create-document", help="Create a new document") doc_parser.add_argument("name", help="Document name") # Create box command box_parser = subparsers.add_parser("create-box", help="Create a box") box_parser.add_argument("--length", type=float, default=10.0, help="Box length") box_parser.add_argument("--width", type=float, default=10.0, help="Box width") box_parser.add_argument("--height", type=float, default=10.0, help="Box height") box_parser.add_argument("--document", help="Document name") box_parser.add_argument("--name", help="Object name") # Create cylinder command cyl_parser = subparsers.add_parser("create-cylinder", help="Create a cylinder") cyl_parser.add_argument("--radius", type=float, default=5.0, help="Cylinder radius") cyl_parser.add_argument( "--height", type=float, default=10.0, help="Cylinder height" ) cyl_parser.add_argument("--document", help="Document name") cyl_parser.add_argument("--name", help="Object name") # Create sphere command sphere_parser = subparsers.add_parser("create-sphere", help="Create a sphere") sphere_parser.add_argument( "--radius", type=float, default=5.0, help="Sphere radius" ) sphere_parser.add_argument("--document", help="Document name") sphere_parser.add_argument("--name", help="Object name") # Export document command export_parser = subparsers.add_parser( "export-document", help="Export document to a file" ) export_parser.add_argument("--path", required=True, help="Output file path") export_parser.add_argument("--document", help="Document name") export_parser.add_argument( "--format", default="step", help="Export format (step, stl, etc.)" ) # Prompt mode command prompt_parser = subparsers.add_parser( "prompt", help="Interactive prompt mode using Google Gemini" ) prompt_parser.add_argument("--api-key", help="Google Gemini API key") args = parser.parse_args() # Create client client = FreeCADClient( host=args.host, port=args.port, timeout=args.timeout, freecad_path=args.freecad, connection_method="connect" if args.connect else "auto" if args.auto else None, ) # Try to connect if not client.is_connected(): print( f"Error: Could not connect to FreeCAD. Please ensure the server is running on {args.host}:{args.port}" ) sys.exit(1) # Get connection type conn_type = client.get_connection_type() print(f"Connected to FreeCAD using {conn_type} method.") # Handle commands if args.command == "prompt": # Get API key from argument, environment, or prompt api_key = args.api_key or os.environ.get("GEMINI_API_KEY") if not api_key: api_key = input("Enter your Gemini API key: ").strip() if not api_key: print("Error: Gemini API key is required for prompt mode") sys.exit(1) interactive_prompt_mode(client, api_key) elif args.command == "version": # Get FreeCAD version version_info = client.get_version() if "error" in version_info: print(f"Error: {version_info['error']}") sys.exit(1) # Print version version = version_info.get("version", ["Unknown"]) build_date = version_info.get("build_date", "Unknown") print(f"FreeCAD Version: {'.'.join(str(v) for v in version)}") print(f"Build Date: {build_date}") elif args.command == "create-document": # Create a document result = client.create_document(args.name) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) print(f"Created document: {result.get('name', 'Unknown')}") elif args.command == "create-box": # Create a box result = client.create_box( length=args.length, width=args.width, height=args.height, document=args.document, name=args.name, ) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) print(f"Created box: {result.get('name', 'Unknown')}") print(f"Dimensions: {args.length} x {args.width} x {args.height}") elif args.command == "create-cylinder": # Create a cylinder result = client.create_cylinder( radius=args.radius, height=args.height, document=args.document, name=args.name, ) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) print(f"Created cylinder: {result.get('name', 'Unknown')}") print(f"Dimensions: radius={args.radius}, height={args.height}") elif args.command == "create-sphere": # Create a sphere result = client.create_sphere( radius=args.radius, document=args.document, name=args.name ) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) print(f"Created sphere: {result.get('name', 'Unknown')}") print(f"Radius: {args.radius}") elif args.command == "export-document": # Export document result = client.export_document( file_path=args.path, file_type=args.format, document=args.document ) if "error" in result: print(f"Error: {result['error']}") sys.exit(1) print(f"Exported document to {args.path}") elif args.command is None: # No command specified, just print connection status print("No command specified. Use --help for available commands.") # Close client client.close() if __name__ == "__main__": main()

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/jango-blockchained/mcp-freecad'

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