Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/KNOWLEDGE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Welcome, AI Developer. Do not load all documentation files into your context. Us

## 2. Features & Interfaces
* **CLI Commands & Routing** ➔ `docs/reference/cli-commands.md`
* **Context Management System** ➔ `docs/ADR-001-Context-System.md`
* **Context Management System** ➔ `docs/architecture/adrs/ADR-001-Context-System.md`
* **CAD Modeling & Examples** ➔ `docs/reference/cad-examples.md`

## 3. Operations & Safety
* **Firmware Audit Log & Vulnerabilities** ➔ `docs/reference/firmware-audit-log.md`
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ Plan.md
coverage/
tmp/
dist/
bundle/
bundle/
__pycache__/
.DS_Store__pycache__/
158 changes: 158 additions & 0 deletions assets/python/freecad_listener.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import sys
import json
import socket
import threading
import traceback
import FreeCAD
import FreeCADGui

# Import PySide2 for thread-safe GUI execution
from PySide2 import QtCore

class ScriptExecutor(QtCore.QObject):
execute_signal = QtCore.Signal(object)

def __init__(self):
super().__init__()
self.execute_signal.connect(self._run_script, QtCore.Qt.QueuedConnection)

def _run_script(self, func):
func()

def run_in_gui(self, func):
self.execute_signal.emit(func)

# This executor must be instantiated in the main thread (during module import)
executor = ScriptExecutor()

# This script is meant to be injected into FreeCAD via `FreeCAD.exe freecad_listener.py`
# It sets up a local TCP server that listens for JSON commands from FlyCLI.

HOST = '127.0.0.1'
PORT = 9099
for arg in sys.argv[1:]:
try:
PORT = int(arg)
break
except ValueError:
pass

def handle_client(conn):
buffer = ""
while True:
try:
data = conn.recv(4096)
if not data:
break
buffer += data.decode('utf-8')

while '\n' in buffer:
line, buffer = buffer.split('\n', 1)
line = line.strip()
if not line:
continue

try:
request = json.loads(line)
process_request(request, conn)
except json.JSONDecodeError:
send_response(conn, {"status": "ERROR", "error": {"message": "Invalid JSON"}})
except Exception as e:
FreeCAD.Console.PrintError(f"IPC Error: {e}\n")
break
conn.close()

def process_request(request, conn):
req_id = request.get('id', 'unknown')
action = request.get('action')

if action == 'EXECUTE_SCRIPT':
code = request.get('payload', {}).get('code', '')

import queue
res_queue = queue.Queue()

def execute_in_gui():
try:
env = {"FreeCAD": FreeCAD, "FreeCADGui": FreeCADGui, "App": FreeCAD, "Gui": FreeCADGui}
exec(code, env)
if FreeCAD.ActiveDocument:
FreeCAD.ActiveDocument.recompute()
res_queue.put({"status": "SUCCESS", "data": {}})
except Exception as e:
err_msg = traceback.format_exc()
res_queue.put({"status": "ERROR", "error": {"type": type(e).__name__, "message": str(e), "traceback": err_msg}})

executor.run_in_gui(execute_in_gui)

try:
res = res_queue.get(timeout=15)
if res["status"] == "SUCCESS":
send_response(conn, {"id": req_id, "status": "SUCCESS", "data": res["data"]})
else:
send_response(conn, {"id": req_id, "status": "ERROR", "error": res["error"]})
except queue.Empty:
send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": "Execution timed out in GUI thread"}})

elif action == 'GET_STATE':
import queue
res_queue = queue.Queue()

def get_state_in_gui():
try:
doc = FreeCAD.ActiveDocument
if not doc:
res_queue.put({"status": "SUCCESS", "data": {"objects": []}})
return

objs = []
for obj in doc.Objects:
props = {}
for prop in obj.PropertiesList:
try:
props[prop] = str(getattr(obj, prop))
except:
pass
objs.append({"name": obj.Name, "type": obj.TypeId, "properties": props})
res_queue.put({"status": "SUCCESS", "data": {"objects": objs}})
except Exception as e:
err_msg = traceback.format_exc()
res_queue.put({"status": "ERROR", "error": {"type": type(e).__name__, "message": str(e), "traceback": err_msg}})

executor.run_in_gui(get_state_in_gui)

try:
res = res_queue.get(timeout=5)
if res["status"] == "SUCCESS":
send_response(conn, {"id": req_id, "status": "SUCCESS", "data": res["data"]})
else:
send_response(conn, {"id": req_id, "status": "ERROR", "error": res["error"]})
except queue.Empty:
send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": "Get state timed out in GUI thread"}})
else:
send_response(conn, {"id": req_id, "status": "ERROR", "error": {"message": f"Unknown action: {action}"}})

def send_response(conn, response):
try:
msg = json.dumps(response) + "\n"
conn.sendall(msg.encode('utf-8'))
except Exception as e:
FreeCAD.Console.PrintError(f"Failed to send response: {e}\n")

def start_server():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((HOST, PORT))
server.listen(1)
FreeCAD.Console.PrintMessage(f"FlyCLI IPC Listener started on {HOST}:{PORT}\n")

while True:
conn, addr = server.accept()
FreeCAD.Console.PrintMessage(f"Connected to FlyCLI: {addr}\n")
client_thread = threading.Thread(target=handle_client, args=(conn,))
client_thread.daemon = True
client_thread.start()

# Start the server in a background thread so we don't block the FreeCAD GUI
server_thread = threading.Thread(target=start_server)
server_thread.daemon = True
server_thread.start()
158 changes: 158 additions & 0 deletions docs/architecture/ai_cad_views/ddd_interfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Domain-Driven Design (DDD) & Interface Contracts

This document defines the Ubiquitous Language for the interactive CAD modeling domain and describes the interaction contracts between components (including IPC).

## 📖 Domain Dictionary (Ubiquitous Language)

| Term | Description |
| :--- | :--- |
| **CadAgent** | The orchestrator inside FlyCLI that manages the interaction loop (User ↔ Gemini ↔ CadEngine). |
| **EnvironmentManager** | The component responsible for verifying, downloading, and extracting the portable FreeCAD environment. Ensures zero-friction startup. |
| **CadEngineProcess** | The running `FreeCAD.exe` process, spawned and managed by FlyCLI (via Node.js `child_process`). |
| **IpcBridge** | The two-way communication channel (TCP socket) between FlyCLI (Node.js) and the CadEngineProcess (Python listener). |
| **GeometryScript** | The Python/CadQuery code generated by the LLM to build or modify the 3D model. |
| **EngineState** | The current state of the open FreeCAD document (list of objects, basic parameters) returned via IpcBridge to inform the LLM about manual user changes. |

---

## 🧩 Interfaces (JavaScript Contracts)

Since the project uses pure JavaScript (ES Modules), these interfaces are described as abstract JSDoc contracts. They will serve as blueprints for the actual implementation.

### 1. Interface: `IEnvironmentManager`
Manages the lifecycle of the portable FreeCAD environment.

```javascript
/**
* @interface IEnvironmentManager
*/
const IEnvironmentManager = {
/**
* Checks for the environment, downloads it if necessary.
* @returns {Promise<string>} Absolute path to the FreeCAD.exe executable.
*/
ensureEnvironmentReady: async () => {},

/**
* Returns current status (e.g., download progress for CLI display).
* @returns {Object} { status: 'downloading'|'ready', progress: 45 }
*/
getStatus: () => {}
};
```

### 2. Interface: `ICadEngineProcess`
Manages the FreeCAD process and IPC communication.

```javascript
/**
* @interface ICadEngineProcess
*/
const ICadEngineProcess = {
/**
* Starts the FreeCAD process and connects to the IpcBridge.
* @param {string} executablePath
* @returns {Promise<void>}
*/
start: async (executablePath) => {},

/**
* Stops the FreeCAD process.
*/
stop: async () => {},

/**
* Sends a GeometryScript to FreeCAD for execution.
* @param {string} scriptContent Python/CadQuery code
* @returns {Promise<Object>} Execution result (success or error details)
*/
executeScript: async (scriptContent) => {},

/**
* Reads the object tree from the FreeCAD document.
* @returns {Promise<Object>} Serialized EngineState
*/
getDocumentState: async () => {}
};

### 3. Interface: `IMcpServer`
Manages the Model Context Protocol (MCP) server over stdio.

```javascript
/**
* @interface IMcpServer
*/
const IMcpServer = {
/**
* Starts listening for MCP requests on stdio.
* @returns {Promise<void>}
*/
start: async () => {},

/**
* Stops the MCP server.
*/
stop: () => {}
};
```
```

---

## 📡 IPC Payload Contracts (JSON)

Data is transmitted over a TCP connection between Node.js (Client) and the Python Listener (Server) inside FreeCAD. All messages are JSON formatted, separated by a newline (`\n`).

### 1. Execute Script Request (Node -> Python)
```json
{
"id": "req_12345",
"action": "EXECUTE_SCRIPT",
"payload": {
"code": "import cadquery as cq\nbox = cq.Workplane('XY').box(10,10,10)\nshow_object(box)"
}
}
```

### 2. Execution Response (Python -> Node)
```json
{
"id": "req_12345",
"status": "SUCCESS",
"data": {
"executionTimeMs": 142
}
}
```
*On Error (e.g., LLM generated bad syntax):*
```json
{
"id": "req_12345",
"status": "ERROR",
"error": {
"type": "SyntaxError",
"message": "unexpected indent (line 3)"
}
}
```

### 3. Get State Request (Node -> Python)
```json
{
"id": "req_12346",
"action": "GET_STATE"
}
```

### 4. State Response (Python -> Node)
```json
{
"id": "req_12346",
"status": "SUCCESS",
"data": {
"objects": [
{ "name": "Box", "type": "Part::Feature", "properties": {"Length": 10, "Width": 10, "Height": 10} }
]
}
}
```
30 changes: 30 additions & 0 deletions docs/architecture/ai_cad_views/logical.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 1. Logical View: AI CAD Integration

The Logical View describes the core components and their responsibilities.

## Components

1. **FlyCLI Core (JS):**
- Parses the `flycli cad` command.
- Manages the interaction with the Gemini API.

2. **FreeCAD Manager (JS):**
- Responsible for the lifecycle of the FreeCAD process.
- Checks for the existence of the portable FreeCAD installation.
- Downloads and extracts it if missing.
- Spawns the `FreeCAD.exe` process.

3. **IPC Bridge (JS <-> Python):**
- **Node Side:** An IPC Server (TCP/WS) hosted by FlyCLI.
- **Python Side:** A listener script injected into FreeCAD on startup (`freecad_listener.py`).
- Translates MCP tool calls from Gemini into Python execution requests.

4. **CadQuery Execution Engine (Python inside FreeCAD):**
- Receives CadQuery scripts from the IPC Bridge.
- Executes them in the context of the active FreeCAD document.
- Updates the 3D viewport.

5. **External AI Agent (Any LLM Client):**
- Connects to FlyCLI via Model Context Protocol (MCP) using standard input/output (stdio).
- Generates CadQuery scripts via MCP tool calls.
- Consumes FreeCAD document state (e.g., bounding box, object tree) to refine models.
8 changes: 8 additions & 0 deletions docs/business/OKR.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ Ensure that every automated process involving hardware configuration can be safe

## Pivot Logic & Reasoning
If the JSON parsing fails for AI Agents due to visual artifacts, we must immediately pivot to a pure background headless mode for agents, separating human UI completely into a standalone tool.

## Objective 2: Empower Amateurs with AI-Assisted CAD Design
Transform FlyCLI from a mere configuration tool into a hardware development assistant by integrating an AI-driven, interactive 3D modeling workflow.

**Key Results:**
- **KR2.1:** Implement the `flycli cad` command that seamlessly orchestrates the FreeCAD GUI from Node.js by `v1.3.0`.
- **KR2.2:** Establish a reliable IPC/CadQuery bridge allowing the AI (Gemini) to generate and instantly visualize solid models without user scripting.
- **KR2.3:** Ensure a zero-friction distribution strategy where users don't need to manually configure FreeCAD paths or Python environments.
Loading
Loading