-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathfreecad_client.py
executable file
·807 lines (668 loc) · 27.5 KB
/
freecad_client.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
#!/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 socket
import sys
import os
import requests
import textwrap
from typing import Optional, Dict, Any
# 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-1.5-pro: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()