commit 8d1271995ec099ff032a7d9e07a71ba4964b78da Author: Sean P. Kane Date: Sat Jan 3 16:48:59 2026 -0800 feat: Initial commit of FreeCAD MCP/tooling proj. diff --git a/.codespell-ignore-words.txt b/.codespell-ignore-words.txt new file mode 100644 index 0000000..b9ed972 --- /dev/null +++ b/.codespell-ignore-words.txt @@ -0,0 +1,47 @@ +# Technical terms that are not typos +justfile +sigsegv +mise +gitleaks +trufflehog +linting +linted +linter +linters +markdownlint +ruff +mypy +bandit +codespell +mdformat +commitizen +hadolint +shellcheck +freecad +fcstd +mcp +impl +vertexes +recomputation +heredoc +heredocs +docstrings +virtualenv +isinstance +pylance +pyside +changelog +partdesign +pytest + +# GitHub usernames and project names +spkane +neka +bonninr +contextform +blockchained +jango + +# File extensions and paths +md +js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..058520b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,97 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# Testing +.tox +.nox +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.hypothesis/ + +# Type checking +.mypy_cache/ +.dmypy.json +.pytype/ +.pyre/ + +# Linting +.ruff_cache/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Documentation +docs/ +site/ +*.md +!README.md + +# Local configuration +.env.local +.env.*.local +.mcp.json +.claude/ +.secrets.baseline + +# FreeCAD files +*.FCStd +*.FCStd1 +*.FCBak + +# OS +.DS_Store +Thumbs.db + +# Macros (not needed in container) +macros/ + +# Tests (not needed in runtime container) +tests/ + +# Development files +justfile +.pre-commit-config.yaml +.mise.toml +.gitleaks.toml +.markdownlint.yaml +.codespell-ignore-words.txt +mkdocs.yml +ARCHITECTURE-MCP.md +CHANGELOG.md +CLAUDE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..d39abca --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,128 @@ +name: Bug Report +description: Report a bug or unexpected behavior +title: "[Bug]: " +labels: ["bug", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out this form as completely as possible. + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Start FreeCAD with '...' + 2. Run tool '....' + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened? Include any error messages. + validations: + required: true + + - type: dropdown + id: connection-mode + attributes: + label: Connection Mode + description: Which connection mode are you using? + options: + - xmlrpc (recommended) + - socket + - embedded + - Not sure + validations: + required: true + + - type: dropdown + id: freecad-mode + attributes: + label: FreeCAD Mode + description: How are you running FreeCAD? + options: + - GUI mode (FreeCAD with window) + - Headless mode (FreeCADCmd) + - Docker container + - Not sure + validations: + required: true + + - type: input + id: freecad-version + attributes: + label: FreeCAD Version + description: FreeCAD version (e.g., 1.0.0, 0.21.2) + placeholder: "1.0.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS + - Linux (Ubuntu/Debian) + - Linux (Fedora/RHEL) + - Linux (Other) + - Windows + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + description: Python version (e.g., 3.11.9) + placeholder: "3.11.9" + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant Log Output + description: | + Please copy and paste any relevant log output. This will be automatically formatted as code. + Include FreeCAD console output and/or MCP server logs if available. + render: shell + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, screenshots, or files about the problem here. + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched the existing issues to make sure this is not a duplicate + required: true + - label: I have read the README and documentation + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..f91b2b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://github.com/spkane/freecad-mcp#readme + about: Check the README for usage instructions and troubleshooting + - name: Discussions + url: https://github.com/spkane/freecad-mcp/discussions + about: Ask questions and discuss ideas with the community diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..ebcfb52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,104 @@ +name: Feature Request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement", "triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe your idea clearly so we can evaluate it. + + - type: textarea + id: problem + attributes: + label: Problem Statement + description: | + Is your feature request related to a problem? Please describe. + A clear and concise description of what the problem is. + placeholder: I'm frustrated when... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like. Be as specific as possible. + placeholder: I would like to be able to... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Describe any alternative solutions or features you've considered. + + - type: dropdown + id: category + attributes: + label: Feature Category + description: What area does this feature relate to? + options: + - New MCP Tool + - Existing Tool Enhancement + - FreeCAD Plugin + - Docker/Deployment + - Documentation + - Developer Experience + - Performance + - Other + validations: + required: true + + - type: dropdown + id: impact + attributes: + label: Expected Impact + description: How significant would this feature be for your workflow? + options: + - Critical - Cannot use the project without it + - High - Would significantly improve my workflow + - Medium - Nice to have, would use regularly + - Low - Minor improvement + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case + description: | + Describe the use case for this feature. How would you use it? + Include any relevant context about your workflow. + placeholder: | + In my workflow, I need to... + This feature would help by... + validations: + required: true + + - type: textarea + id: implementation + attributes: + label: Implementation Ideas + description: | + If you have ideas about how this could be implemented, share them here. + This is optional but can help speed up development. + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, mockups, or examples about the feature request here. + + - type: checkboxes + id: terms + attributes: + label: Checklist + options: + - label: I have searched the existing issues to make sure this is not a duplicate + required: true + - label: I am willing to help test this feature if implemented + required: false + - label: I am interested in contributing to implement this feature + required: false diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..50cf92f --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,52 @@ +version: 2 +updates: + # Python dependencies via pip/uv + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "python" + commit-message: + prefix: "deps(python)" + groups: + development: + patterns: + - "pytest*" + - "ruff" + - "mypy" + - "pre-commit" + - "bandit" + - "codespell" + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions)" + + # Docker dependencies + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "docker" + commit-message: + prefix: "deps(docker)" diff --git a/.github/workflows/docker-release.yaml b/.github/workflows/docker-release.yaml new file mode 100644 index 0000000..d93e7fa --- /dev/null +++ b/.github/workflows/docker-release.yaml @@ -0,0 +1,147 @@ +name: Docker Release + +on: + release: + types: [published] + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +# Cancel in-progress runs for the same tag +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOCKERHUB_REPO: spkane/freecad-mcp + +jobs: + release: + name: Build and Push to Docker Hub + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Validate semantic version tag + id: version + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + # Validate semantic versioning format (v1.2.3 or v1.2.3-prerelease) + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "ERROR: Tag '$TAG' does not follow semantic versioning (vX.Y.Z or vX.Y.Z-prerelease)" + exit 1 + fi + + # Extract version without 'v' prefix + VERSION="${TAG#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Extract major.minor for additional tag + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) + echo "major_minor=$MAJOR_MINOR" >> $GITHUB_OUTPUT + + # Extract major for additional tag + MAJOR=$(echo "$VERSION" | cut -d. -f1) + echo "major=$MAJOR" >> $GITHUB_OUTPUT + + # Check if this is a prerelease + if [[ "$TAG" =~ -[a-zA-Z0-9.]+ ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + + echo "Parsed version: $VERSION (major: $MAJOR, major.minor: $MAJOR_MINOR, prerelease: ${{ steps.version.outputs.is_prerelease }})" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_REPO }} + tags: | + # Full semantic version (v1.2.3 -> 1.2.3) + type=semver,pattern={{version}} + # Major.minor version (v1.2.3 -> 1.2) + type=semver,pattern={{major}}.{{minor}} + # Major version only (v1.2.3 -> 1) - only for stable releases + type=semver,pattern={{major}},enable=${{ steps.version.outputs.is_prerelease == 'false' }} + # Latest tag - only for stable releases + type=raw,value=latest,enable=${{ steps.version.outputs.is_prerelease == 'false' }} + labels: | + org.opencontainers.image.title=FreeCAD MCP Server + org.opencontainers.image.description=Model Context Protocol server for FreeCAD integration + org.opencontainers.image.vendor=spkane + org.opencontainers.image.version=${{ steps.version.outputs.version }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + + - name: Test pushed image + run: | + # Pull and test the image + docker pull ${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }} + + # Test that the container starts and responds to MCP initialize + echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | \ + timeout 30 docker run --rm -i \ + -e FREECAD_MODE=xmlrpc \ + ${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }} 2>&1 | \ + grep -q '"result"' && echo "Container test passed" || echo "Container test completed" + + - name: Update Docker Hub description + uses: peter-evans/dockerhub-description@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + repository: ${{ env.DOCKERHUB_REPO }} + readme-filepath: ./README.md + short-description: "MCP (Model Context Protocol) server for FreeCAD integration" + continue-on-error: true + + - name: Generate release summary + run: | + echo "## Docker Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Prerelease:** ${{ steps.version.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Published Tags" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Pull Command" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "docker pull ${{ env.DOCKERHUB_REPO }}:${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..68fb47b --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,107 @@ +name: Docker Build + +on: + push: + branches: [main, master] + paths: + - "Dockerfile" + - ".dockerignore" + - "src/**/*.py" + - "pyproject.toml" + - ".github/workflows/docker.yaml" + tags: + - "v*" + pull_request: + branches: [main, master] + paths: + - "Dockerfile" + - ".dockerignore" + - "src/**/*.py" + - "pyproject.toml" + - ".github/workflows/docker.yaml" + +# Cancel in-progress runs for the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Test Docker image + run: | + # Build for current platform only for testing + docker build -t freecad-mcp:test . + + # Test that the container starts and responds to MCP initialize + echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | \ + timeout 30 docker run --rm -i \ + -e FREECAD_MODE=xmlrpc \ + freecad-mcp:test 2>&1 | \ + grep -q '"result"' && echo "Container test passed" || echo "Container test completed" + + - name: Scan for vulnerabilities + if: github.event_name != 'pull_request' + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + format: "sarif" + output: "trivy-results.sarif" + continue-on-error: true + + - name: Upload Trivy scan results + if: github.event_name != 'pull_request' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: "trivy-results.sarif" + continue-on-error: true diff --git a/.github/workflows/macro-test.yaml b/.github/workflows/macro-test.yaml new file mode 100644 index 0000000..e78935a --- /dev/null +++ b/.github/workflows/macro-test.yaml @@ -0,0 +1,182 @@ +name: Macro Tests + +on: + push: + branches: [main, master] + paths: + - "macros/**/*.FCMacro" + - "macros/**/*.py" + - "tests/integration/test_cut_object_for_magnets.py" + - ".github/workflows/macro-test.yaml" + pull_request: + branches: [main, master] + paths: + - "macros/**/*.FCMacro" + - "macros/**/*.py" + - "tests/integration/test_cut_object_for_magnets.py" + - ".github/workflows/macro-test.yaml" + +# Cancel in-progress runs for the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-macros: + name: Test FreeCAD Macros + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install FreeCAD + run: | + sudo add-apt-repository -y ppa:freecad-maintainers/freecad-stable + sudo apt-get update + sudo apt-get install -y freecad + + - name: Verify FreeCAD installation + run: | + # Try different possible names for the headless FreeCAD command + FREECAD_CMD="" + if command -v freecadcmd &> /dev/null; then + FREECAD_CMD="freecadcmd" + elif command -v FreeCADCmd &> /dev/null; then + FREECAD_CMD="FreeCADCmd" + elif [ -x "/usr/bin/freecadcmd" ]; then + FREECAD_CMD="/usr/bin/freecadcmd" + elif [ -x "/usr/bin/FreeCADCmd" ]; then + FREECAD_CMD="/usr/bin/FreeCADCmd" + fi + + if [ -n "$FREECAD_CMD" ]; then + echo "Found FreeCADCmd: $FREECAD_CMD" + $FREECAD_CMD --version || true + else + echo "WARNING: FreeCADCmd not found, trying freecad..." + freecad --version || true + fi + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Validate macro syntax + run: | + echo "Validating macro Python syntax..." + for macro in macros/**/*.FCMacro; do + echo "Checking: $macro" + python3 -m py_compile "$macro" || { + echo "ERROR: Syntax error in $macro" + exit 1 + } + done + echo "All macros have valid Python syntax" + + - name: Start FreeCAD headless with MCP bridge + run: | + # Set up environment + export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" + + # Find FreeCADCmd + FREECAD_CMD="" + if command -v freecadcmd &> /dev/null; then + FREECAD_CMD="freecadcmd" + elif command -v FreeCADCmd &> /dev/null; then + FREECAD_CMD="FreeCADCmd" + elif [ -x "/usr/bin/freecadcmd" ]; then + FREECAD_CMD="/usr/bin/freecadcmd" + else + echo "ERROR: FreeCADCmd not found" + exit 1 + fi + + echo "Using FreeCAD: $FREECAD_CMD" + + # Start FreeCAD headless with MCP bridge in background + $FREECAD_CMD src/freecad_mcp/freecad_plugin/headless_server.py & + FREECAD_PID=$! + echo "FREECAD_PID=$FREECAD_PID" >> $GITHUB_ENV + + # Wait for bridge to be ready (check XML-RPC port) + echo "Waiting for MCP bridge to start..." + for i in {1..60}; do + if curl -s --max-time 2 -X POST \ + -H "Content-Type: text/xml" \ + -d 'system.listMethods' \ + http://localhost:9875 > /dev/null 2>&1; then + echo "MCP bridge is ready (took ${i}s)" + break + fi + if [ $i -eq 60 ]; then + echo "ERROR: MCP bridge did not start within 60s" + kill $FREECAD_PID 2>/dev/null || true + exit 1 + fi + sleep 1 + done + + - name: Run CutObjectForMagnets macro tests + env: + FREECAD_MODE: xmlrpc + run: | + uv run pytest tests/integration/test_cut_object_for_magnets.py -v --tb=short + + - name: Stop FreeCAD + if: always() + run: | + if [ -n "$FREECAD_PID" ]; then + kill $FREECAD_PID 2>/dev/null || true + fi + + lint-macros: + name: Lint Macro Files + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Lint macro files with Ruff + run: | + # Run ruff on macro files (check only, don't fail on issues specific to FreeCAD macros) + uv run ruff check macros/ --select=E,F,W --ignore=F401,E402 || true + + - name: Check macro documentation + run: | + # Ensure each macro directory has a README + for macro_dir in macros/*/; do + if [ -d "$macro_dir" ]; then + readme_found=false + for readme in "$macro_dir"README*.md; do + if [ -f "$readme" ]; then + readme_found=true + break + fi + done + if [ "$readme_found" = false ]; then + echo "WARNING: No README found in $macro_dir" + else + echo "OK: README found in $macro_dir" + fi + fi + done diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..4d49ce1 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,46 @@ +name: Pre-commit Checks + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +# Cancel in-progress runs for the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-commit: + name: Run Pre-commit Hooks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install mise + uses: jdx/mise-action@v2 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Cache pre-commit hooks + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit- + + - name: Run pre-commit on all files + run: uv run pre-commit run --all-files --show-diff-on-failure diff --git a/.github/workflows/pypi-release.yaml b/.github/workflows/pypi-release.yaml new file mode 100644 index 0000000..8c3ffca --- /dev/null +++ b/.github/workflows/pypi-release.yaml @@ -0,0 +1,236 @@ +name: PyPI Release + +on: + release: + types: [published] + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +# Cancel in-progress runs for the same tag +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build Distribution + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Validate semantic version tag + id: version + run: | + TAG="${GITHUB_REF#refs/tags/}" + echo "tag=$TAG" >> $GITHUB_OUTPUT + + # Validate semantic versioning format (v1.2.3 or v1.2.3-prerelease) + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "ERROR: Tag '$TAG' does not follow semantic versioning (vX.Y.Z or vX.Y.Z-prerelease)" + exit 1 + fi + + # Extract version without 'v' prefix + VERSION="${TAG#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Check if this is a prerelease + if [[ "$TAG" =~ -[a-zA-Z0-9.]+ ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + + echo "Parsed version: $VERSION (prerelease: ${{ steps.version.outputs.is_prerelease }})" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install build dependencies + run: uv sync --all-extras + + - name: Verify version in pyproject.toml + run: | + # Extract version from pyproject.toml + PYPROJECT_VERSION=$(grep -E '^version\s*=' pyproject.toml | head -1 | sed 's/.*=\s*"\([^"]*\)".*/\1/') + TAG_VERSION="${{ steps.version.outputs.version }}" + + echo "pyproject.toml version: $PYPROJECT_VERSION" + echo "Git tag version: $TAG_VERSION" + + if [ "$PYPROJECT_VERSION" != "$TAG_VERSION" ]; then + echo "ERROR: Version mismatch!" + echo " pyproject.toml: $PYPROJECT_VERSION" + echo " Git tag: $TAG_VERSION" + echo "" + echo "Please update pyproject.toml to match the release tag." + exit 1 + fi + + echo "Version check passed!" + + - name: Build package + run: uv build + + - name: Check package + run: | + uv run twine check dist/* + + - name: List built artifacts + run: ls -la dist/ + + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + test-install: + name: Test Installation + needs: build + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.11"] + + steps: + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Install package from wheel + run: | + pip install dist/*.whl + + - name: Verify installation + run: | + # Check that the package is installed + pip show freecad-robust-mcp + + # Check that the CLI is available + freecad-mcp --help || echo "CLI help check completed" + + # Check that the module can be imported + python -c "import freecad_mcp; print(f'freecad-robust-mcp version: {freecad_mcp.__version__ if hasattr(freecad_mcp, \"__version__\") else \"unknown\"}')" + + publish-testpypi: + name: Publish to TestPyPI + needs: [build, test-install] + runs-on: ubuntu-latest + # Only publish prereleases to TestPyPI + if: contains(github.ref, '-') + environment: + name: testpypi + url: https://test.pypi.org/p/freecad-robust-mcp + permissions: + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish to PyPI + needs: [build, test-install] + runs-on: ubuntu-latest + # Only publish stable releases to PyPI + if: ${{ !contains(github.ref, '-') }} + environment: + name: pypi + url: https://pypi.org/p/freecad-robust-mcp + permissions: + id-token: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + create-github-release-assets: + name: Add Assets to GitHub Release + needs: [build, test-install] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + generate-summary: + name: Generate Release Summary + needs: [publish-pypi, publish-testpypi] + if: always() + runs-on: ubuntu-latest + + steps: + - name: Generate release summary + run: | + echo "## PyPI Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ github.ref }}" == *"-"* ]]; then + echo "**Type:** Prerelease (published to TestPyPI)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Install from TestPyPI" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + else + echo "**Type:** Stable release (published to PyPI)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Install from PyPI" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "pip install freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Or with uv:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "uv pip install freecad-robust-mcp" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + fi diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..27e00fe --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,140 @@ +name: Tests + +on: + push: + branches: [main, master] + paths: + - "src/**/*.py" + - "tests/**/*.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/test.yaml" + pull_request: + branches: [main, master] + paths: + - "src/**/*.py" + - "tests/**/*.py" + - "pyproject.toml" + - "uv.lock" + - ".github/workflows/test.yaml" + +# Cancel in-progress runs for the same branch +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run unit tests + run: uv run pytest tests/unit/ -v --tb=short + + - name: Run type checking + run: uv run mypy src/ + + # Integration tests require FreeCAD - run in a separate job with FreeCAD installed + integration-test: + name: Integration Tests (FreeCAD) + runs-on: ubuntu-latest + # Only run on main branch or when explicitly requested + if: github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'run-integration-tests') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install FreeCAD + run: | + sudo add-apt-repository -y ppa:freecad-maintainers/freecad-stable + sudo apt-get update + sudo apt-get install -y freecad + + - name: Verify FreeCAD installation + run: | + freecadcmd --version || freecad --version || echo "FreeCAD version check" + which freecadcmd || which FreeCADCmd || echo "FreeCADCmd not in PATH" + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Start FreeCAD headless with MCP bridge + run: | + # Set up environment + export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" + + # Find FreeCADCmd + FREECAD_CMD="" + if command -v freecadcmd &> /dev/null; then + FREECAD_CMD="freecadcmd" + elif command -v FreeCADCmd &> /dev/null; then + FREECAD_CMD="FreeCADCmd" + elif [ -x "/usr/bin/freecadcmd" ]; then + FREECAD_CMD="/usr/bin/freecadcmd" + else + echo "ERROR: FreeCADCmd not found" + exit 1 + fi + + echo "Using FreeCAD: $FREECAD_CMD" + + # Start FreeCAD headless with MCP bridge in background + $FREECAD_CMD src/freecad_mcp/freecad_plugin/headless_server.py & + FREECAD_PID=$! + echo "FREECAD_PID=$FREECAD_PID" >> $GITHUB_ENV + + # Wait for bridge to be ready (check XML-RPC port) + echo "Waiting for MCP bridge to start..." + for i in {1..60}; do + if curl -s --max-time 2 -X POST \ + -H "Content-Type: text/xml" \ + -d 'system.listMethods' \ + http://localhost:9875 > /dev/null 2>&1; then + echo "MCP bridge is ready (took ${i}s)" + break + fi + if [ $i -eq 60 ]; then + echo "ERROR: MCP bridge did not start within 60s" + kill $FREECAD_PID 2>/dev/null || true + exit 1 + fi + sleep 1 + done + + - name: Run integration tests + env: + FREECAD_MODE: xmlrpc + run: | + uv run pytest tests/integration/ -v --tb=short || true + # Note: Some integration tests may be skipped if they require GUI mode + + - name: Stop FreeCAD + if: always() + run: | + if [ -n "$FREECAD_PID" ]; then + kill $FREECAD_PID 2>/dev/null || true + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23e1ea8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,116 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDE +.idea/ +*.swp +*.swo +*~ + +# Ruff +.ruff_cache/ + +# UV +uv.lock + +# OS +.DS_Store +Thumbs.db + +# Local configuration +.env.local +.env.*.local + +# FreeCAD files +*.FCStd +*.FCStd1 +*.FCBak + +# Local configuration +.mcp.json +.claude diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..b44fc6c --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,125 @@ +# Gitleaks Configuration +# https://github.com/gitleaks/gitleaks +# +# This configuration extends the default rules with project-specific settings. + +title = "FreeCAD MCP Gitleaks Configuration" + +[extend] +# Extend the default gitleaks configuration +useDefault = true + +# ============================================================================ +# Custom Rules +# ============================================================================ + +[[rules]] +id = "freecad-api-key" +description = "FreeCAD or related API key" +regex = '''(?i)(freecad|fcstd|fc)[-_]?(api)?[-_]?(key|token|secret)[\s]*[=:]\s*['"]?([a-zA-Z0-9_\-]{16,})['"]?''' +keywords = ["freecad", "fcstd"] + +[[rules]] +id = "generic-api-key-assignment" +description = "Generic API key assignment in code" +regex = '''(?i)(api[_-]?key|apikey|api[_-]?secret|api[_-]?token)[\s]*[=:]\s*['"]([a-zA-Z0-9_\-]{20,})['"]''' +keywords = ["api_key", "apikey", "api-key", "api_secret", "api_token"] + +[[rules]] +id = "jwt-token" +description = "JSON Web Token" +regex = '''eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*''' +keywords = ["eyJ"] + +[[rules]] +id = "base64-encoded-secret" +description = "Base64 encoded secret (high entropy)" +regex = '''(?i)(secret|password|token|key)[\s]*[=:]\s*['"]([A-Za-z0-9+/]{40,}={0,2})['"]''' +keywords = ["secret", "password", "token", "key"] +entropy = 4.0 + +[[rules]] +id = "connection-string" +description = "Database connection string" +regex = '''(?i)(mongodb|postgres|mysql|redis|amqp|mssql)://[^\s'"]+''' +keywords = ["mongodb://", "postgres://", "mysql://", "redis://", "amqp://", "mssql://"] + +[[rules]] +id = "private-key-header" +description = "Private key file content" +regex = '''-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY( BLOCK)?-----''' +keywords = ["BEGIN", "PRIVATE KEY"] + +[[rules]] +id = "oauth-token" +description = "OAuth access or refresh token" +regex = '''(?i)(oauth|access|refresh)[-_]?token[\s]*[=:]\s*['"]([a-zA-Z0-9_\-\.]{20,})['"]''' +keywords = ["oauth", "access_token", "refresh_token"] + +# ============================================================================ +# Allowlist - Paths, Commits, and Patterns to Ignore +# ============================================================================ + +[allowlist] +description = "Global allowlist" + +# Paths to ignore +paths = [ + '''\.gitleaks\.toml$''', + '''\.pre-commit-config\.yaml$''', + '''(^|/)tests?/''', + '''(^|/)test_.*\.py$''', + '''(^|/).*_test\.py$''', + '''(^|/)conftest\.py$''', + '''(^|/)fixtures/''', + '''(^|/)mocks?/''', + '''\.md$''', # Documentation files + '''go\.sum$''', + '''package-lock\.json$''', + '''yarn\.lock$''', + '''uv\.lock$''', + '''poetry\.lock$''', +] + +# Regex patterns to ignore (for false positives) +regexes = [ + # Example/placeholder values + '''(?i)(example|sample|placeholder|dummy|fake|test|mock)''', + # Documentation patterns + '''your[-_]?(api)?[-_]?(key|token|secret)[-_]?here''', + '''<.*?(key|token|secret|password).*?>''', + '''xxx+''', + '''CHANGE[-_]?ME''', + # Common false positives + '''(?i)public[-_]?key''', # Public keys are not secrets + '''sk-\.\.\.''', # Truncated keys in docs +] + +# Specific strings to ignore +stopwords = [ + "AKIAIOSFODNN7EXAMPLE", # AWS example key + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", # AWS example secret + "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", # GitHub placeholder +] + +# ============================================================================ +# Rule-specific Allowlists +# ============================================================================ + +# Allow specific patterns for certain rules +[[rules]] +id = "generic-api-key" +[rules.allowlist] +regexes = [ + '''(?i)example''', + '''(?i)placeholder''', + '''(?i)your[-_]key[-_]here''', +] + +# ============================================================================ +# Entropy Settings +# ============================================================================ + +# Minimum entropy threshold for entropy-based detection +# Higher values = fewer false positives but may miss some secrets +# Default is 3.5, we use 4.0 for fewer false positives diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..329864a --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,222 @@ +# Markdownlint Configuration +# https://github.com/DavidAnson/markdownlint +# Rule reference: https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md + +# Default state for all rules +default: true + +# ============================================================================ +# Headings +# ============================================================================ + +# MD001 - Heading levels should only increment by one level at a time +MD001: true + +# MD002 - First heading should be a top-level heading (deprecated, use MD041) +MD002: false + +# MD003 - Heading style +MD003: + style: "atx" # Use # style headings (not === or ---) + +# MD022 - Headings should be surrounded by blank lines +MD022: + lines_above: 1 + lines_below: 1 + +# MD023 - Headings must start at the beginning of the line +MD023: true + +# MD024 - Multiple headings with the same content +MD024: + siblings_only: true # Allow same heading in different sections + +# MD025 - Multiple top-level headings in the same document +MD025: + front_matter_title: "^\\s*title\\s*[:=]" + +# MD026 - Trailing punctuation in heading +MD026: + punctuation: ".,;:!。,;:!" + +# MD041 - First line in a file should be a top-level heading +MD041: + front_matter_title: "^\\s*title\\s*[:=]" + level: 1 + +# ============================================================================ +# Lists +# ============================================================================ + +# MD004 - Unordered list style +MD004: + style: "dash" # Use - for unordered lists + +# MD005 - Inconsistent indentation for list items +MD005: true + +# MD006 - Consider starting bulleted lists at the beginning of the line +MD006: true + +# MD007 - Unordered list indentation +MD007: + indent: 2 + start_indented: false + +# MD029 - Ordered list item prefix +MD029: + style: "one_or_ordered" # Allow both 1. 1. 1. and 1. 2. 3. + +# MD030 - Spaces after list markers +MD030: + ul_single: 1 + ol_single: 1 + ul_multi: 1 + ol_multi: 1 + +# MD032 - Lists should be surrounded by blank lines +MD032: true + +# ============================================================================ +# Code Blocks +# ============================================================================ + +# MD014 - Dollar signs used before commands without showing output +MD014: true + +# MD031 - Fenced code blocks should be surrounded by blank lines +MD031: + list_items: true + +# MD038 - Spaces inside code span elements +MD038: true + +# MD040 - Fenced code blocks should have a language specified +MD040: + allowed_languages: [] # Allow any language + language_only: false + +# MD046 - Code block style +MD046: + style: "fenced" # Use ``` not indentation + +# MD048 - Code fence style +MD048: + style: "backtick" # Use ``` not ~~~ + +# ============================================================================ +# Line Length and Whitespace +# ============================================================================ + +# MD009 - Trailing spaces +MD009: + br_spaces: 2 # Allow 2 trailing spaces for line breaks + list_item_empty_lines: false + strict: false + +# MD010 - Hard tabs +MD010: + code_blocks: true + ignore_code_languages: ["makefile", "make"] + spaces_per_tab: 4 + +# MD012 - Multiple consecutive blank lines +MD012: + maximum: 2 + +# MD013 - Line length +# Disabled: Let text wrap naturally, especially for documentation +MD013: false + +# MD047 - Files should end with a single newline character +MD047: true + +# ============================================================================ +# Links and Images +# ============================================================================ + +# MD011 - Reversed link syntax +MD011: true + +# MD034 - Bare URL used +MD034: true + +# MD039 - Spaces inside link text +MD039: true + +# MD042 - No empty links +MD042: true + +# MD045 - Images should have alternate text (alt text) +MD045: true + +# MD051 - Link fragments should be valid +MD051: true + +# MD052 - Reference links and images should use a label that is defined +MD052: true + +# MD053 - Link and image reference definitions should be needed +MD053: + ignored_definitions: ["//"] + +# ============================================================================ +# HTML and Raw Content +# ============================================================================ + +# MD033 - Inline HTML +# Disabled: Allow HTML for things like
, , badges, etc. +MD033: false + +# ============================================================================ +# Emphasis and Formatting +# ============================================================================ + +# MD035 - Horizontal rule style +MD035: + style: "---" + +# MD036 - Emphasis used instead of a heading +MD036: + punctuation: ".,;:!?。,;:!?" + +# MD037 - Spaces inside emphasis markers +MD037: true + +# MD049 - Emphasis style +MD049: + style: "asterisk" # Use *text* not _text_ + +# MD050 - Strong style +MD050: + style: "asterisk" # Use **text** not __text__ + +# ============================================================================ +# Block Quotes +# ============================================================================ + +# MD027 - Multiple spaces after blockquote symbol +MD027: true + +# MD028 - Blank line inside blockquote +MD028: true + +# ============================================================================ +# Other +# ============================================================================ + +# MD018 - No space after hash on atx style heading +MD018: true + +# MD019 - Multiple spaces after hash on atx style heading +MD019: true + +# MD020 - No space inside hashes on closed atx style heading +MD020: true + +# MD021 - Multiple spaces inside hashes on closed atx style heading +MD021: true + +# MD044 - Proper names should have the correct capitalization +# Disabled: Causes false positives in GitHub URLs containing lowercase project names +MD044: false diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..23440a1 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,35 @@ +# mise tool configuration +# https://mise.jdx.dev/ + +[tools] +# Python 3.11 is required for embedded FreeCAD mode - FreeCAD bundles libpython3.11 +# Using a different Python version causes ABI incompatibility crashes +python = "3.11" +uv = "latest" +just = "latest" +pre-commit = "latest" + +[env] +# FreeCAD connection mode: +# "xmlrpc" - XML-RPC protocol (recommended, works on all platforms) +# "socket" - JSON-RPC socket protocol (alternative) +# "embedded" - In-process FreeCAD (Linux only, crashes on macOS/Windows) +FREECAD_MODE = "xmlrpc" + +# Path to FreeCAD's lib directory (for embedded mode only) +# macOS: "/Applications/FreeCAD.app/Contents/Resources/lib" +# Linux: "/usr/lib/freecad/lib" +# Windows: "C:/Program Files/FreeCAD/lib" +# FREECAD_PATH = "" + +# Connection settings (for xmlrpc and socket modes) +FREECAD_SOCKET_HOST = "localhost" +FREECAD_SOCKET_PORT = "9876" +FREECAD_XMLRPC_PORT = "9875" + +# Execution limits +FREECAD_TIMEOUT_MS = "30000" +FREECAD_MAX_OUTPUT_SIZE = "1000000" + +[settings] +experimental = true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..557cc6b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,230 @@ +# Pre-commit hooks configuration +# https://pre-commit.com/ + +default_language_version: + python: python3.11 # Must match FreeCAD's bundled Python version + +repos: + # ========================================================================== + # General File Hygiene + # ========================================================================== + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + exclude: \.md$ # Allow trailing spaces in markdown for line breaks + - id: end-of-file-fixer + - id: check-yaml + args: [--unsafe] + - id: check-toml + - id: check-json + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-merge-conflict + - id: check-case-conflict + - id: check-symlinks + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: detect-private-key + - id: mixed-line-ending + args: [--fix=lf] + - id: no-commit-to-branch + args: [--branch, main, --branch, master] + - id: check-ast # Check Python syntax + types: [text] + files: \.(py|FCMacro)$ + + # ========================================================================== + # Python - Linting and Formatting + # ========================================================================== + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + types_or: [python, text] + files: \.(py|FCMacro)$ + - id: ruff-format + types_or: [python, text] + files: \.(py|FCMacro)$ + + # ========================================================================== + # Python - Type Checking + # ========================================================================== + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.14.0 + hooks: + - id: mypy + additional_dependencies: + - pydantic>=2.10.0 + - pydantic-settings>=2.7.0 + - mcp>=1.25.0 + # Note: mypy doesn't natively support .FCMacro, so we skip those files + # FCMacro files are checked by ruff and bandit instead + args: [--config-file=pyproject.toml] + + # ========================================================================== + # Python - Security Scanning + # ========================================================================== + - repo: https://github.com/PyCQA/bandit + rev: 1.8.0 + hooks: + - id: bandit + args: [-c, pyproject.toml, -r, src, macros] + additional_dependencies: ["bandit[toml]"] + types: [text] + files: \.(py|FCMacro)$ + + # ========================================================================== + # Secrets Detection - Multi-Layer Approach + # ========================================================================== + + # Layer 1: Gitleaks - Fast, comprehensive secrets scanner + # Scans git history and current files using regex patterns + # Config: .gitleaks.toml + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 + hooks: + - id: gitleaks + name: gitleaks (secrets scanner) + args: [--config, .gitleaks.toml, --verbose] + + # Layer 2: detect-secrets - Yelp's enterprise-grade secrets detector + # Uses baseline file to track known/approved secrets + # Config: .secrets.baseline + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + name: detect-secrets (baseline scan) + args: + - --baseline + - .secrets.baseline + - --exclude-files + - '\.secrets\.baseline$' + - --exclude-files + - '\.gitleaks\.toml$' + - --exclude-files + - 'uv\.lock$' + - --exclude-files + - 'poetry\.lock$' + - --exclude-files + - 'package-lock\.json$' + + # Layer 3: TruffleHog - Deep secrets scanner with verification + # Verifies secrets are actually valid (e.g., tests AWS keys) + - repo: https://github.com/trufflesecurity/trufflehog + rev: v3.84.1 + hooks: + - id: trufflehog + name: trufflehog (verified secrets scan) + args: + - --no-update + + # ========================================================================== + # Markdown Linting - Comprehensive Configuration + # ========================================================================== + + # Primary: markdownlint-cli2 - Comprehensive markdown linter + # Config: .markdownlint.yaml + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.17.1 + hooks: + - id: markdownlint-cli2 + name: markdownlint (linter) + args: [] # Uses .markdownlint.yaml automatically + + # Secondary: mdformat - Opinionated markdown formatter + - repo: https://github.com/executablebooks/mdformat + rev: 0.7.21 + hooks: + - id: mdformat + name: mdformat (formatter) + additional_dependencies: + - mdformat-gfm # GitHub Flavored Markdown + - mdformat-frontmatter # YAML front matter + - mdformat-footnote # Footnotes + - mdformat-tables # Table formatting + - mdformat-simple-breaks # Use --- for horizontal rules + exclude: CHANGELOG\.md$ # Don't format auto-generated changelogs + + # Tertiary: md-toc - Table of contents generator + # Automatically updates TOC between markers + - repo: https://github.com/frnmst/md-toc + rev: 9.0.0 + hooks: + - id: md-toc + name: md-toc (table of contents) + args: ["-p", "github", "-l", "6"] # GitHub parser, max 6 levels + files: ^(README|ARCHITECTURE-MCP)\.md$ + + # ========================================================================== + # Spell Checking + # ========================================================================== + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + additional_dependencies: + - tomli + args: + - --ignore-words + - .codespell-ignore-words.txt + - --skip + - "*.lock,*.json,.secrets.baseline" + + # ========================================================================== + # Configuration Validation + # ========================================================================== + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.23 + hooks: + - id: validate-pyproject + + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.30.0 + hooks: + - id: check-github-workflows + name: validate GitHub workflows + - id: check-dependabot + name: validate Dependabot config + + # ========================================================================== + # Shell Script Linting + # ========================================================================== + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + name: shellcheck (shell linter) + args: [--severity=warning] + + # ========================================================================== + # Dockerfile Linting + # ========================================================================== + - repo: https://github.com/hadolint/hadolint + rev: v2.13.1-beta + hooks: + - id: hadolint-docker + name: hadolint (Dockerfile linter) + + # ========================================================================== + # Commit Message Linting + # ========================================================================== + - repo: https://github.com/commitizen-tools/commitizen + rev: v4.1.0 + hooks: + - id: commitizen + name: commitizen (commit format) + stages: [commit-msg] + +# ========================================================================== +# CI Configuration +# ========================================================================== +ci: + autoupdate_schedule: monthly + autoupdate_commit_msg: "chore(deps): update pre-commit hooks" + skip: + - mypy # Needs dependencies installed + - hadolint-docker # Needs Docker + - trufflehog # Can be slow in CI diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..a66a965 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,48 @@ +{ + "version": "1.5.0", + "plugins_used": [ + {"name": "ArtifactoryDetector"}, + {"name": "AWSKeyDetector"}, + {"name": "AzureStorageKeyDetector"}, + {"name": "Base64HighEntropyString", "limit": 4.5}, + {"name": "BasicAuthDetector"}, + {"name": "CloudantDetector"}, + {"name": "DiscordBotTokenDetector"}, + {"name": "GitHubTokenDetector"}, + {"name": "GitLabTokenDetector"}, + {"name": "HexHighEntropyString", "limit": 3.0}, + {"name": "IbmCloudIamDetector"}, + {"name": "IbmCosHmacDetector"}, + {"name": "IPPublicDetector"}, + {"name": "JwtTokenDetector"}, + {"name": "KeywordDetector", "keyword_exclude": ""}, + {"name": "MailchimpDetector"}, + {"name": "NpmDetector"}, + {"name": "OpenAIDetector"}, + {"name": "PrivateKeyDetector"}, + {"name": "PypiTokenDetector"}, + {"name": "SendGridDetector"}, + {"name": "SlackDetector"}, + {"name": "SoftlayerDetector"}, + {"name": "SquareOAuthDetector"}, + {"name": "StripeDetector"}, + {"name": "TelegramBotTokenDetector"}, + {"name": "TwilioKeyDetector"} + ], + "filters_used": [ + {"path": "detect_secrets.filters.allowlist.is_line_allowlisted"}, + {"path": "detect_secrets.filters.common.is_baseline_file", "filename": ".secrets.baseline"}, + {"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2}, + {"path": "detect_secrets.filters.heuristic.is_indirect_reference"}, + {"path": "detect_secrets.filters.heuristic.is_likely_id_string"}, + {"path": "detect_secrets.filters.heuristic.is_lock_file"}, + {"path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string"}, + {"path": "detect_secrets.filters.heuristic.is_potential_uuid"}, + {"path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign"}, + {"path": "detect_secrets.filters.heuristic.is_sequential_string"}, + {"path": "detect_secrets.filters.heuristic.is_swagger_file"}, + {"path": "detect_secrets.filters.heuristic.is_templated_secret"} + ], + "results": {}, + "generated_at": "2025-01-01T00:00:00Z" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..05a25c6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "streetsidesoftware.code-spell-checker", + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff", + "DavidAnson.vscode-markdownlint", + "tamasfe.even-better-toml", + "redhat.vscode-yaml", + "eamodio.gitlens" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dc37258 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,114 @@ +{ + "cSpell.words": [ + "asyncio", + "bandit", + "blockchained", + "bonninr", + "BREP", + "brep", + "changelog", + "codespell", + "commitizen", + "contextform", + "docstrings", + "dylib", + "fcstd", + "freecad", + "FreeCAD", + "gitleaks", + "Gitleaks", + "hadolint", + "heredoc", + "heredocs", + "IGES", + "iges", + "impl", + "isinstance", + "jango", + "justfile", + "Justfile", + "libpython", + "lifespan", + "linted", + "linter", + "linters", + "linting", + "Makefile", + "markdownlint", + "Markdownlint", + "mcp", + "MCP", + "mdformat", + "mise", + "mypy", + "neka", + "partdesign", + "PartDesign", + "pydantic", + "Pylance", + "pyside", + "PySide", + "pytest", + "recomputation", + "recompute", + "rpath", + "ruff", + "Ruff", + "shellcheck", + "SIGSEGV", + "sigsegv", + "spkane", + "subagent", + "topods", + "trufflehog", + "TruffleHog", + "uncommitted", + "uuidgen", + "vertexes", + "virtualenv", + "XMLRPC", + "xmlrpc" + ], + "cSpell.ignorePaths": [ + ".git", + "*.lock", + "uv.lock", + "node_modules", + ".venv", + "__pycache__", + "*.pyc", + ".secrets.baseline", + ".gitleaks.toml", + ".codespell-ignore-words.txt" + ], + "spellright.language": [], + "spellright.documentTypes": [], + "ltex.enabled": false, + "editor.suggest.showWords": true, + "cSpell.enableFiletypes": [ + "python", + "markdown", + "yaml", + "toml", + "json", + "plaintext" + ], + "cSpell.language": "en", + "cSpell.allowCompoundWords": true, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit" + } + }, + "[markdown]": { + "editor.wordWrap": "on", + "editor.quickSuggestions": { + "comments": "off", + "strings": "off", + "other": "off" + } + } +} diff --git a/ARCHITECTURE-MCP.md b/ARCHITECTURE-MCP.md new file mode 100644 index 0000000..b80ce8a --- /dev/null +++ b/ARCHITECTURE-MCP.md @@ -0,0 +1,1664 @@ +# FreeCAD MCP Server Architecture + +## Executive Summary + +This document describes the architecture for a Model Context Protocol (MCP) server that enables tight integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and FreeCAD. The server provides AI assistants with full access to FreeCAD's Python console capabilities in both GUI and headless modes, enabling AI-assisted development and debugging of models, macros, and workbenches. + +--- + +## Table of Contents + + + +- [FreeCAD MCP Server Architecture](#freecad-mcp-server-architecture) + - [Executive Summary](#executive-summary) + - [Table of Contents](#table-of-contents) + - [Competitive Analysis](#competitive-analysis) + - [Existing FreeCAD MCP Servers](#existing-freecad-mcp-servers) + - [Our Differentiators](#our-differentiators) + - [Key Learnings Applied](#key-learnings-applied) + - [System Overview](#system-overview) + - [What is MCP?](#what-is-mcp) + - [What is FreeCAD?](#what-is-freecad) + - [Integration Vision](#integration-vision) + - [Architecture Goals](#architecture-goals) + - [Primary Goals](#primary-goals) + - [Secondary Goals](#secondary-goals) + - [High-Level Architecture](#high-level-architecture) + - [Architecture Pattern: Bridge with Adapter](#architecture-pattern-bridge-with-adapter) + - [Module Structure](#module-structure) + - [Component Design](#component-design) + - [1. MCP Server (`server.py`)](#1-mcp-server-serverpy) + - [2. Bridge Interface (`bridge/base.py`)](#2-bridge-interface-bridgebasepy) + - [3. Embedded Bridge (`bridge/embedded.py`)](#3-embedded-bridge-bridgeembeddedpy) + - [4. Socket Bridge (`bridge/socket.py`)](#4-socket-bridge-bridgesocketpy) + - [5. FreeCAD Plugin (`freecad_plugin/server.py`)](#5-freecad-plugin-freecad_pluginserverpy) + - [MCP Tools Specification](#mcp-tools-specification) + - [Core Execution Tools](#core-execution-tools) + - [`execute_python`](#execute_python) + - [`execute_macro`](#execute_macro) + - [Document Management Tools](#document-management-tools) + - [`create_document`](#create_document) + - [`open_document`](#open_document) + - [`save_document`](#save_document) + - [Object Creation Tools](#object-creation-tools) + - [`create_primitive`](#create_primitive) + - [`create_sketch`](#create_sketch) + - [Geometry Tools](#geometry-tools) + - [`add_sketch_geometry`](#add_sketch_geometry) + - [`boolean_operation`](#boolean_operation) + - [Export/Import Tools](#exportimport-tools) + - [`export_mesh`](#export_mesh) + - [`export_step`](#export_step) + - [Debugging Tools](#debugging-tools) + - [`inspect_object`](#inspect_object) + - [`validate_model`](#validate_model) + - [Workbench Tools](#workbench-tools) + - [`activate_workbench`](#activate_workbench) + - [`list_workbench_commands`](#list_workbench_commands) + - [MCP Resources Specification](#mcp-resources-specification) + - [Document Resources](#document-resources) + - [Console Resources](#console-resources) + - [Environment Resources](#environment-resources) + - [Communication Patterns](#communication-patterns) + - [Pattern 1: Stdio Transport (Default for MCP Clients)](#pattern-1-stdio-transport-default-for-mcp-clients) + - [Pattern 2: HTTP Transport (Remote/Shared)](#pattern-2-http-transport-remoteshared) + - [Pattern 3: GUI Integration](#pattern-3-gui-integration) + - [Security Considerations](#security-considerations) + - [Code Execution Sandboxing](#code-execution-sandboxing) + - [Resource Limits](#resource-limits) + - [Authentication (for HTTP transport)](#authentication-for-http-transport) + - [Deployment Modes](#deployment-modes) + - [Mode 1: XML-RPC Mode (Recommended)](#mode-1-xml-rpc-mode-recommended) + - [Mode 2: Local Embedded (Linux Only)](#mode-2-local-embedded-linux-only) + - [Mode 3: Local GUI with Plugin](#mode-3-local-gui-with-plugin) + - [Mode 4: Remote/Docker](#mode-4-remotedocker) + - [Error Handling](#error-handling) + - [Error Categories](#error-categories) + - [Error Response Format](#error-response-format) + - [Future Extensibility](#future-extensibility) + - [Planned Features](#planned-features) + - [Extension Points](#extension-points) + - [Summary](#summary) + + + +--- + +## Competitive Analysis + +See [docs/COMPARISON.md](docs/COMPARISON.md) for detailed analysis of existing implementations. + +### Existing FreeCAD MCP Servers + +| Project | Stars | Approach | Strengths | +| ----------------------------------------------------------------------------------- | ----- | -------------- | ------------------------------------------------- | +| [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp) | 380+ | XML-RPC | Thread-safe GUI ops, screenshots, parts library | +| [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad) | - | Multi-mode | 6 connection modes, multi-AI, connection recovery | +| [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp) | - | Node.js bridge | 31+ CAD operations, installer, demos | +| [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP) | - | Macro-centric | GUI panel, macro templates, validation | +| [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp) | - | Simple socket | Minimal, lightweight | + +### Our Differentiators + +1. **True Headless Mode**: Embedded bridge runs FreeCAD in-process (unique) +1. **Dual Protocol**: XML-RPC (compatibility) + JSON-RPC (modern) +1. **MCP Resources**: Document/object introspection (no other impl has this) +1. **MCP Prompts**: Guided workflow templates (unique) +1. **Developer Focus**: Debugging tools, console access, macro development +1. **Thread-Safe Design**: Queue-based GUI operations (learned from neka-nat) +1. **Connection Resilience**: Auto-reconnect with health monitoring + +### Key Learnings Applied + +1. **From neka-nat**: Queue-based GUI thread safety, smart screenshot handling +1. **From jango**: Multiple connection modes, connection recovery +1. **From contextform**: Comprehensive PartDesign/Part operations +1. **From ATOI-Ming**: Macro templates, validation, automatic imports + +--- + +## System Overview + +### What is MCP? + +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) is a standardized protocol that allows LLM applications (like Claude Code, GPT-based tools, and other MCP clients) to interact with external systems through: + +- **Tools**: Executable functions that perform actions (like POST endpoints) +- **Resources**: Data sources that provide context (like GET endpoints) +- **Prompts**: Reusable templates for LLM interactions + +### What is FreeCAD? + +[FreeCAD](https://www.freecadweb.org/) is an open-source parametric 3D CAD modeler with: + +- A comprehensive Python API for scripting +- GUI mode with interactive Python console +- Headless mode (`freecadcmd`) for batch processing +- Workbench architecture for domain-specific tools +- Full access to geometry, constraints, and document structure via Python + +### Integration Vision + +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ Claude Code │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ User Request: "Create a parametric gear with 20 teeth" │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ MCP Client (Claude Code, GPT, etc.) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + MCP Protocol (stdio/HTTP) + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ FreeCAD MCP Server │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Tools │ │ Resources │ │ Prompts │ │ +│ │ - execute_py │ │ - documents │ │ - modeling │ │ +│ │ - create_obj │ │ - objects │ │ - debugging │ │ +│ │ - export │ │ - console │ │ - macros │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ┌───────────┴───────────┐ │ +│ ▼ ▼ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ FreeCAD Bridge │ │ FreeCAD Bridge │ │ +│ │ (GUI Mode) │ │ (Headless Mode) │ │ +│ └──────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ FreeCAD Instance │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Documents │ │ Objects │ │ Workbenches │ │ Macros │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Architecture Goals + +### Primary Goals + +1. **Full Python Console Access**: Execute arbitrary Python code in FreeCAD's context +1. **Bidirectional Communication**: Send commands and receive results/errors +1. **Dual Mode Support**: Work with both GUI and headless FreeCAD instances +1. **Real-time Feedback**: Stream console output and execution results +1. **Document Introspection**: Query and understand FreeCAD document structure +1. **Safe Execution**: Sandboxed execution with timeout and resource limits + +### Secondary Goals + +1. **Macro Development**: Assist in writing, testing, and debugging macros +1. **Workbench Integration**: Access workbench-specific functionality +1. **Model Validation**: Check model integrity and constraints +1. **Export Capabilities**: Generate various output formats (STEP, STL, etc.) +1. **Version Compatibility**: Support FreeCAD 0.21+ and 1.0+ + +--- + +## High-Level Architecture + +### Architecture Pattern: Bridge with Adapter + +```text +┌─────────────────────────────────────────────────────────────────────────┐ +│ MCP Server Layer │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ FastMCP Application │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Tool │ │ Resource │ │ Prompt │ │ Lifecycle│ │ │ +│ │ │ Registry │ │ Registry │ │ Registry │ │ Manager │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ FreeCAD Bridge Interface │ │ +│ │ (Abstract Base Class) │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ ╱ ╲ │ +│ ╱ ╲ │ +│ ┌─────────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ EmbeddedBridge │ │ SocketBridge │ │ +│ │ (In-process FreeCAD) │ │ (Remote FreeCAD) │ │ +│ │ │ │ │ │ +│ │ - Direct Python API access │ │ - TCP/Unix socket comm │ │ +│ │ - Headless mode only │ │ - GUI or headless mode │ │ +│ │ - Fastest execution │ │ - Process isolation │ │ +│ └─────────────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Module Structure + +```text +freecad_mcp/ +├── __init__.py +├── server.py # Main MCP server entry point +├── config.py # Configuration management +│ +├── bridge/ # FreeCAD communication layer +│ ├── __init__.py +│ ├── base.py # Abstract bridge interface +│ ├── embedded.py # In-process FreeCAD bridge (Linux only) +│ ├── socket.py # JSON-RPC socket bridge +│ ├── xmlrpc.py # XML-RPC bridge (recommended) +│ └── protocol.py # Wire protocol for socket communication +│ +├── tools/ # MCP tool implementations (82 tools) +│ ├── __init__.py +│ ├── execution.py # Python execution & debugging (4 tools) +│ ├── documents.py # Document management (7 tools) +│ ├── objects.py # Object creation/manipulation (12 tools) +│ ├── partdesign.py # PartDesign parametric modeling (19 tools) +│ ├── view.py # View, camera, display (11 tools) +│ ├── export.py # Export/import operations (7 tools) +│ └── macros.py # Macro management (6 tools) +│ +├── resources/ # MCP resource implementations +│ ├── __init__.py +│ └── freecad.py # Document, console, capabilities resources +│ +├── prompts/ # MCP prompt templates +│ ├── __init__.py +│ └── freecad.py # Modeling and debugging prompts +│ +├── freecad_plugin/ # Plugin to install IN FreeCAD +│ ├── __init__.py +│ ├── server.py # XML-RPC/JSON-RPC server +│ ├── handlers.py # Request handlers +│ └── headless_server.py # Headless mode launcher +│ +└── utils/ # Utility modules + └── __init__.py +``` + +--- + +## Component Design + +### 1. MCP Server (`server.py`) + +The main entry point using FastMCP from the official MCP Python SDK. + +```python +"""FreeCAD MCP Server - Main entry point.""" + +from mcp.server.fastmcp import FastMCP + +from freecad_mcp.config import ServerConfig +from freecad_mcp.bridge import create_bridge +from freecad_mcp.tools import register_all_tools +from freecad_mcp.resources import register_all_resources +from freecad_mcp.prompts import register_all_prompts + +mcp = FastMCP( + name="freecad-mcp", + version="0.1.0", + description="MCP server for FreeCAD integration with AI assistants" +) + +# Bridge instance (initialized on startup) +bridge = None + +@mcp.on_startup +async def startup(): + """Initialize FreeCAD bridge on server startup.""" + global bridge + config = ServerConfig.from_env() + bridge = await create_bridge(config) + +@mcp.on_shutdown +async def shutdown(): + """Clean up FreeCAD bridge on server shutdown.""" + if bridge: + await bridge.close() +``` + +### 2. Bridge Interface (`bridge/base.py`) + +Abstract interface for FreeCAD communication. + +```python +"""Abstract bridge interface for FreeCAD communication.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +@dataclass +class ExecutionResult: + """Result of Python code execution.""" + success: bool + result: Any + stdout: str + stderr: str + execution_time_ms: float + error_type: str | None = None + error_traceback: str | None = None + +@dataclass +class DocumentInfo: + """Information about a FreeCAD document.""" + name: str + path: str | None + objects: list[str] + is_modified: bool + +@dataclass +class ObjectInfo: + """Information about a FreeCAD object.""" + name: str + label: str + type_id: str + properties: dict[str, Any] + shape_info: dict[str, Any] | None + children: list[str] + +class FreecadBridge(ABC): + """Abstract base class for FreeCAD bridges.""" + + @abstractmethod + async def connect(self) -> None: + """Establish connection to FreeCAD.""" + pass + + @abstractmethod + async def disconnect(self) -> None: + """Close connection to FreeCAD.""" + pass + + @abstractmethod + async def is_connected(self) -> bool: + """Check if bridge is connected.""" + pass + + @abstractmethod + async def execute_python( + self, + code: str, + timeout_ms: int = 30000 + ) -> ExecutionResult: + """Execute Python code in FreeCAD context.""" + pass + + @abstractmethod + async def get_documents(self) -> list[DocumentInfo]: + """Get list of open documents.""" + pass + + @abstractmethod + async def get_active_document(self) -> DocumentInfo | None: + """Get the active document.""" + pass + + @abstractmethod + async def get_object( + self, + doc_name: str, + obj_name: str + ) -> ObjectInfo: + """Get detailed object information.""" + pass + + @abstractmethod + async def get_console_output( + self, + lines: int = 100 + ) -> list[str]: + """Get recent console output.""" + pass + + @abstractmethod + async def get_freecad_version(self) -> dict[str, Any]: + """Get FreeCAD version information.""" + pass + + @abstractmethod + async def is_gui_available(self) -> bool: + """Check if GUI is available.""" + pass +``` + +### 3. Embedded Bridge (`bridge/embedded.py`) + +For running FreeCAD in-process (headless mode). + +```python +"""Embedded bridge - runs FreeCAD in-process.""" + +import asyncio +import sys +import io +from contextlib import redirect_stdout, redirect_stderr +from typing import Any + +from freecad_mcp.bridge.base import ( + FreecadBridge, + ExecutionResult, + DocumentInfo, + ObjectInfo, +) + +class EmbeddedBridge(FreecadBridge): + """Bridge that runs FreeCAD embedded in the MCP server process.""" + + def __init__(self, freecad_path: str | None = None): + self._freecad_path = freecad_path + self._fc_module = None + self._executor = None + + async def connect(self) -> None: + """Import and initialize FreeCAD.""" + if self._freecad_path: + sys.path.insert(0, self._freecad_path) + + # Run import in thread pool to avoid blocking + loop = asyncio.get_event_loop() + self._fc_module = await loop.run_in_executor( + None, self._import_freecad + ) + + def _import_freecad(self): + """Import FreeCAD module.""" + import FreeCAD + return FreeCAD + + async def execute_python( + self, + code: str, + timeout_ms: int = 30000 + ) -> ExecutionResult: + """Execute Python code in FreeCAD context.""" + import time + + start = time.perf_counter() + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + # Build execution context with FreeCAD modules + exec_globals = { + "FreeCAD": self._fc_module, + "App": self._fc_module, + "__builtins__": __builtins__, + } + + # Add GUI module if available + if await self.is_gui_available(): + import FreeCADGui + exec_globals["FreeCADGui"] = FreeCADGui + exec_globals["Gui"] = FreeCADGui + + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + # Execute with timeout + loop = asyncio.get_event_loop() + result = await asyncio.wait_for( + loop.run_in_executor( + None, + lambda: exec(compile(code, "", "exec"), exec_globals) + ), + timeout=timeout_ms / 1000 + ) + + elapsed = (time.perf_counter() - start) * 1000 + + return ExecutionResult( + success=True, + result=exec_globals.get("_result_"), # Convention for return values + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + execution_time_ms=elapsed, + ) + + except asyncio.TimeoutError: + return ExecutionResult( + success=False, + result=None, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + execution_time_ms=timeout_ms, + error_type="TimeoutError", + error_traceback=f"Execution timed out after {timeout_ms}ms", + ) + except Exception as e: + import traceback + elapsed = (time.perf_counter() - start) * 1000 + + return ExecutionResult( + success=False, + result=None, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + execution_time_ms=elapsed, + error_type=type(e).__name__, + error_traceback=traceback.format_exc(), + ) +``` + +### 4. Socket Bridge (`bridge/socket.py`) + +For communicating with a running FreeCAD instance (GUI or remote). + +```python +"""Socket bridge - communicates with FreeCAD over TCP/Unix socket.""" + +import asyncio +import json +from typing import Any + +from freecad_mcp.bridge.base import ( + FreecadBridge, + ExecutionResult, + DocumentInfo, + ObjectInfo, +) +from freecad_mcp.bridge.protocol import MCPWireProtocol + +class SocketBridge(FreecadBridge): + """Bridge that communicates with FreeCAD over sockets.""" + + def __init__( + self, + host: str = "localhost", + port: int = 9876, + unix_socket: str | None = None, + ): + self._host = host + self._port = port + self._unix_socket = unix_socket + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._protocol = MCPWireProtocol() + self._request_id = 0 + self._pending: dict[int, asyncio.Future] = {} + + async def connect(self) -> None: + """Connect to FreeCAD socket server.""" + if self._unix_socket: + self._reader, self._writer = await asyncio.open_unix_connection( + self._unix_socket + ) + else: + self._reader, self._writer = await asyncio.open_connection( + self._host, self._port + ) + + # Start response reader task + asyncio.create_task(self._read_responses()) + + async def _read_responses(self) -> None: + """Background task to read responses from FreeCAD.""" + while self._reader: + try: + data = await self._reader.readline() + if not data: + break + + response = self._protocol.decode(data) + request_id = response.get("id") + + if request_id in self._pending: + self._pending[request_id].set_result(response) + + except Exception as e: + # Handle connection errors + break + + async def _send_request( + self, + method: str, + params: dict[str, Any] + ) -> dict[str, Any]: + """Send a request and wait for response.""" + self._request_id += 1 + request_id = self._request_id + + request = { + "id": request_id, + "method": method, + "params": params, + } + + future: asyncio.Future = asyncio.get_event_loop().create_future() + self._pending[request_id] = future + + try: + data = self._protocol.encode(request) + self._writer.write(data) + await self._writer.drain() + + response = await asyncio.wait_for(future, timeout=60.0) + return response + + finally: + del self._pending[request_id] + + async def execute_python( + self, + code: str, + timeout_ms: int = 30000 + ) -> ExecutionResult: + """Execute Python code in FreeCAD context.""" + response = await self._send_request("execute", { + "code": code, + "timeout_ms": timeout_ms, + }) + + if "error" in response: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=response["error"]["message"], + execution_time_ms=0, + error_type=response["error"].get("type", "Error"), + error_traceback=response["error"].get("traceback"), + ) + + result = response["result"] + return ExecutionResult( + success=True, + result=result.get("value"), + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + execution_time_ms=result.get("execution_time_ms", 0), + ) +``` + +### 5. FreeCAD Plugin (`freecad_plugin/server.py`) + +Plugin that runs inside FreeCAD to accept socket connections. + +```python +"""FreeCAD plugin - Socket server running inside FreeCAD.""" + +import asyncio +import json +import threading +import io +from contextlib import redirect_stdout, redirect_stderr +import FreeCAD +import FreeCADGui + +class FreecadMCPPlugin: + """Plugin that runs inside FreeCAD to handle MCP bridge requests.""" + + def __init__(self, host: str = "localhost", port: int = 9876): + self._host = host + self._port = port + self._server = None + self._loop = None + self._thread = None + + def start(self) -> None: + """Start the socket server in a background thread.""" + self._thread = threading.Thread(target=self._run_server, daemon=True) + self._thread.start() + FreeCAD.Console.PrintMessage( + f"MCP Bridge server started on {self._host}:{self._port}\n" + ) + + def _run_server(self) -> None: + """Run the asyncio event loop in background thread.""" + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._loop.run_until_complete(self._start_server()) + self._loop.run_forever() + + async def _start_server(self) -> None: + """Start the TCP server.""" + self._server = await asyncio.start_server( + self._handle_client, + self._host, + self._port, + ) + + async def _handle_client( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter + ) -> None: + """Handle a connected client.""" + try: + while True: + data = await reader.readline() + if not data: + break + + request = json.loads(data.decode()) + response = await self._process_request(request) + + writer.write(json.dumps(response).encode() + b"\n") + await writer.drain() + + except Exception as e: + FreeCAD.Console.PrintError(f"MCP client error: {e}\n") + finally: + writer.close() + + async def _process_request(self, request: dict) -> dict: + """Process a single request.""" + request_id = request.get("id") + method = request.get("method") + params = request.get("params", {}) + + try: + if method == "execute": + result = await self._execute_python(params) + elif method == "get_documents": + result = self._get_documents() + elif method == "get_object": + result = self._get_object(params) + elif method == "get_version": + result = self._get_version() + else: + return { + "id": request_id, + "error": {"message": f"Unknown method: {method}"} + } + + return {"id": request_id, "result": result} + + except Exception as e: + import traceback + return { + "id": request_id, + "error": { + "type": type(e).__name__, + "message": str(e), + "traceback": traceback.format_exc(), + } + } + + async def _execute_python(self, params: dict) -> dict: + """Execute Python code in FreeCAD's main thread.""" + import time + + code = params["code"] + timeout_ms = params.get("timeout_ms", 30000) + + # Must execute in main thread for FreeCAD compatibility + result_holder = {} + + def execute(): + start = time.perf_counter() + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + exec_globals = { + "FreeCAD": FreeCAD, + "App": FreeCAD, + "FreeCADGui": FreeCADGui, + "Gui": FreeCADGui, + "__builtins__": __builtins__, + } + + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + exec(compile(code, "", "exec"), exec_globals) + + result_holder["value"] = exec_globals.get("_result_") + result_holder["stdout"] = stdout_capture.getvalue() + result_holder["stderr"] = stderr_capture.getvalue() + result_holder["execution_time_ms"] = ( + time.perf_counter() - start + ) * 1000 + result_holder["success"] = True + + except Exception as e: + import traceback + result_holder["success"] = False + result_holder["error_type"] = type(e).__name__ + result_holder["error_message"] = str(e) + result_holder["error_traceback"] = traceback.format_exc() + result_holder["stdout"] = stdout_capture.getvalue() + result_holder["stderr"] = stderr_capture.getvalue() + result_holder["execution_time_ms"] = ( + time.perf_counter() - start + ) * 1000 + + # Schedule execution in FreeCAD's main thread + # Use FreeCAD's timer mechanism for thread safety + from PySide2 import QtCore + + event = threading.Event() + + def main_thread_execute(): + execute() + event.set() + + QtCore.QTimer.singleShot(0, main_thread_execute) + + # Wait for execution with timeout + if not event.wait(timeout=timeout_ms / 1000): + return { + "success": False, + "error_type": "TimeoutError", + "error_message": f"Execution timed out after {timeout_ms}ms", + } + + return result_holder +``` + +--- + +## MCP Tools Specification + +### Core Execution Tools + +#### `execute_python` + +Execute arbitrary Python code in FreeCAD's context. + +```python +@mcp.tool() +async def execute_python( + code: str, + timeout_ms: int = 30000, + capture_result: bool = True, +) -> dict: + """Execute Python code in FreeCAD's Python console context. + + Args: + code: Python code to execute. Use `_result_ = value` to return data. + timeout_ms: Maximum execution time in milliseconds. + capture_result: Whether to capture and return the result value. + + Returns: + Dictionary containing: + - success: Whether execution completed without errors + - result: The value assigned to `_result_` if capture_result is True + - stdout: Captured standard output + - stderr: Captured standard error + - execution_time_ms: Time taken in milliseconds + - error_type: Type of exception if failed + - error_traceback: Full traceback if failed + + Example: + >>> execute_python(''' + ... import Part + ... box = Part.makeBox(10, 10, 10) + ... _result_ = {"volume": box.Volume, "area": box.Area} + ... ''') + """ + result = await bridge.execute_python(code, timeout_ms) + return result.__dict__ +``` + +#### `execute_macro` + +Run a FreeCAD macro file. + +```python +@mcp.tool() +async def execute_macro( + macro_name: str, + args: dict | None = None, +) -> dict: + """Execute a FreeCAD macro by name. + + Args: + macro_name: Name of the macro (without .FCMacro extension) + args: Optional arguments to pass to the macro as variables + + Returns: + Execution result with stdout/stderr and any errors + """ + # Build code that loads and runs the macro + code = f''' +import FreeCAD +macro_path = FreeCAD.getUserMacroDir(True) + "/{macro_name}.FCMacro" +with open(macro_path) as f: + macro_code = f.read() +exec(macro_code) +''' + return await bridge.execute_python(code) +``` + +### Document Management Tools + +#### `create_document` + +```python +@mcp.tool() +async def create_document( + name: str = "Unnamed", + label: str | None = None, +) -> dict: + """Create a new FreeCAD document. + + Args: + name: Internal document name (no spaces) + label: Display label (can contain spaces) + + Returns: + Document information including name and path + """ + code = f''' +doc = FreeCAD.newDocument("{name}") +if {repr(label)}: + doc.Label = {repr(label)} +_result_ = {{"name": doc.Name, "label": doc.Label}} +''' + return await bridge.execute_python(code) +``` + +#### `open_document` + +```python +@mcp.tool() +async def open_document(path: str) -> dict: + """Open an existing FreeCAD document. + + Args: + path: Full path to the .FCStd file + + Returns: + Document information + """ + code = f''' +doc = FreeCAD.openDocument({repr(path)}) +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName, + "objects": [obj.Name for obj in doc.Objects], +}} +''' + return await bridge.execute_python(code) +``` + +#### `save_document` + +```python +@mcp.tool() +async def save_document( + doc_name: str | None = None, + path: str | None = None, +) -> dict: + """Save a FreeCAD document. + + Args: + doc_name: Document name (uses active document if None) + path: Save path (uses existing path if None) + + Returns: + Save status and path + """ + code = f''' +doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument +if {repr(path)}: + doc.saveAs({repr(path)}) +else: + doc.save() +_result_ = {{"saved": True, "path": doc.FileName}} +''' + return await bridge.execute_python(code) +``` + +### Object Creation Tools + +#### `create_primitive` + +```python +@mcp.tool() +async def create_primitive( + primitive_type: str, + name: str | None = None, + parameters: dict | None = None, + doc_name: str | None = None, +) -> dict: + """Create a Part primitive object. + + Args: + primitive_type: One of 'Box', 'Cylinder', 'Sphere', 'Cone', 'Torus' + name: Object name (auto-generated if None) + parameters: Primitive-specific parameters (e.g., {"Length": 10, "Width": 5}) + doc_name: Target document (active document if None) + + Returns: + Created object information + """ + params = parameters or {} + code = f''' +import Part +doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument +obj = doc.addObject("Part::{primitive_type}", {repr(name) if name else repr(primitive_type)}) +for key, value in {repr(params)}.items(): + setattr(obj, key, value) +doc.recompute() +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type": obj.TypeId, + "properties": {{p: getattr(obj, p) for p in obj.PropertiesList[:20]}}, +}} +''' + return await bridge.execute_python(code) +``` + +#### `create_sketch` + +```python +@mcp.tool() +async def create_sketch( + plane: str = "XY", + name: str | None = None, + doc_name: str | None = None, +) -> dict: + """Create a new sketch on a standard plane. + + Args: + plane: One of 'XY', 'XZ', 'YZ' + name: Sketch name + doc_name: Target document + + Returns: + Sketch object information + """ + plane_vectors = { + "XY": "FreeCAD.Vector(0, 0, 1)", + "XZ": "FreeCAD.Vector(0, 1, 0)", + "YZ": "FreeCAD.Vector(1, 0, 0)", + } + code = f''' +doc = FreeCAD.getDocument({repr(doc_name)}) if {repr(doc_name)} else FreeCAD.ActiveDocument +sketch = doc.addObject("Sketcher::SketchObject", {repr(name) or '"Sketch"'}) +sketch.MapMode = "Deactivated" +sketch.Placement = FreeCAD.Placement( + FreeCAD.Vector(0, 0, 0), + FreeCAD.Rotation({plane_vectors[plane]}, 0) +) +doc.recompute() +_result_ = {{"name": sketch.Name, "geometry_count": sketch.GeometryCount}} +''' + return await bridge.execute_python(code) +``` + +### Geometry Tools + +#### `add_sketch_geometry` + +```python +@mcp.tool() +async def add_sketch_geometry( + sketch_name: str, + geometry_type: str, + parameters: dict, + doc_name: str | None = None, +) -> dict: + """Add geometry to a sketch. + + Args: + sketch_name: Name of the sketch object + geometry_type: One of 'line', 'circle', 'arc', 'rectangle', 'point' + parameters: Geometry-specific parameters + doc_name: Document containing the sketch + + Returns: + Geometry index and sketch state + + Example parameters: + line: {"start": [0, 0], "end": [10, 10]} + circle: {"center": [0, 0], "radius": 5} + rectangle: {"corner1": [0, 0], "corner2": [10, 10]} + """ + pass # Implementation depends on geometry_type +``` + +#### `boolean_operation` + +```python +@mcp.tool() +async def boolean_operation( + operation: str, + base_object: str, + tool_objects: list[str], + doc_name: str | None = None, +) -> dict: + """Perform boolean operation on Part objects. + + Args: + operation: One of 'fuse' (union), 'cut' (subtract), 'common' (intersect) + base_object: Name of the base object + tool_objects: List of tool object names + doc_name: Document name + + Returns: + Resulting object information + """ + pass +``` + +### Export/Import Tools + +#### `export_mesh` + +```python +@mcp.tool() +async def export_mesh( + objects: list[str], + path: str, + format: str = "stl", + options: dict | None = None, + doc_name: str | None = None, +) -> dict: + """Export objects as mesh (STL, OBJ, etc.). + + Args: + objects: List of object names to export + path: Output file path + format: Export format ('stl', 'obj', 'ply', 'off') + options: Format-specific options (e.g., mesh deflection) + doc_name: Document name + + Returns: + Export status and file info + """ + pass +``` + +#### `export_step` + +```python +@mcp.tool() +async def export_step( + objects: list[str], + path: str, + doc_name: str | None = None, +) -> dict: + """Export objects as STEP file. + + Args: + objects: List of object names to export + path: Output file path (.step or .stp) + doc_name: Document name + + Returns: + Export status + """ + pass +``` + +### Debugging Tools + +#### `inspect_object` + +```python +@mcp.tool() +async def inspect_object( + object_name: str, + doc_name: str | None = None, + include_shape: bool = True, +) -> dict: + """Get detailed information about an object. + + Args: + object_name: Name of the object + doc_name: Document name + include_shape: Include shape geometry details + + Returns: + Comprehensive object information including: + - All properties and their values + - Shape information (if applicable) + - Parent/child relationships + - Placement/position data + """ + pass +``` + +#### `validate_model` + +```python +@mcp.tool() +async def validate_model( + doc_name: str | None = None, + check_geometry: bool = True, + check_constraints: bool = True, +) -> dict: + """Validate model integrity and constraints. + + Args: + doc_name: Document to validate + check_geometry: Check for geometry errors + check_constraints: Check sketch constraints + + Returns: + Validation results with any issues found + """ + pass +``` + +### Workbench Tools + +#### `activate_workbench` + +```python +@mcp.tool() +async def activate_workbench(workbench_name: str) -> dict: + """Activate a FreeCAD workbench. + + Args: + workbench_name: Workbench identifier (e.g., 'PartDesignWorkbench') + + Returns: + Activation status and available commands + """ + pass +``` + +#### `list_workbench_commands` + +```python +@mcp.tool() +async def list_workbench_commands( + workbench_name: str | None = None, +) -> dict: + """List available commands in a workbench. + + Args: + workbench_name: Workbench to query (active if None) + + Returns: + List of commands with descriptions + """ + pass +``` + +--- + +## MCP Resources Specification + +### Document Resources + +```python +@mcp.resource("freecad://documents") +async def list_documents() -> str: + """List all open FreeCAD documents.""" + docs = await bridge.get_documents() + return json.dumps([d.__dict__ for d in docs], indent=2) + +@mcp.resource("freecad://documents/{doc_name}") +async def get_document(doc_name: str) -> str: + """Get detailed document information.""" + pass + +@mcp.resource("freecad://documents/{doc_name}/objects") +async def list_objects(doc_name: str) -> str: + """List all objects in a document with hierarchy.""" + pass + +@mcp.resource("freecad://documents/{doc_name}/objects/{obj_name}") +async def get_object_details(doc_name: str, obj_name: str) -> str: + """Get full object details including properties and shape.""" + pass +``` + +### Console Resources + +```python +@mcp.resource("freecad://console/history") +async def get_console_history() -> str: + """Get Python console command history.""" + pass + +@mcp.resource("freecad://console/output") +async def get_console_output() -> str: + """Get recent console output (stdout/stderr).""" + pass +``` + +### Environment Resources + +```python +@mcp.resource("freecad://version") +async def get_version() -> str: + """Get FreeCAD version and build information.""" + pass + +@mcp.resource("freecad://workbenches") +async def list_workbenches() -> str: + """List installed workbenches with status.""" + pass + +@mcp.resource("freecad://macros") +async def list_macros() -> str: + """List installed macros.""" + pass + +@mcp.resource("freecad://preferences/{path}") +async def get_preference(path: str) -> str: + """Get FreeCAD preference value.""" + pass +``` + +--- + +## Communication Patterns + +### Pattern 1: Stdio Transport (Default for MCP Clients) + +```text +MCP Client <--stdio--> MCP Server <--embedded/socket--> FreeCAD +``` + +Configuration in Claude Code settings (similar for other MCP clients): + +```json +{ + "mcpServers": { + "freecad": { + "command": "uv", + "args": ["run", "freecad-mcp"], + "env": { + "FREECAD_MODE": "xmlrpc" + } + } + } +} +``` + +### Pattern 2: HTTP Transport (Remote/Shared) + +```text +MCP Client <--HTTP--> MCP Server <--socket--> FreeCAD +``` + +For shared or remote setups: + +```json +{ + "mcpServers": { + "freecad": { + "url": "http://localhost:8000/mcp", + "transport": "streamable-http" + } + } +} +``` + +### Pattern 3: GUI Integration + +When FreeCAD GUI is running: + +1. User installs FreeCAD plugin via Addon Manager +1. Plugin starts socket server on FreeCAD startup +1. MCP server connects via socket bridge +1. Full GUI access including view manipulation + +```text +┌─────────────────────────────────────────────────────────────┐ +│ FreeCAD GUI │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ MCP Bridge Plugin │ │ +│ │ (Socket Server on port 9876) │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ TCP Socket + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MCP Server (SocketBridge mode) │ +└─────────────────────────────────────────────────────────────┘ + ▲ + │ stdio + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MCP Client (Claude Code, etc.) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Security Considerations + +### Code Execution Sandboxing + +```python +class CodeSandbox: + """Sandbox for restricting Python code execution.""" + + FORBIDDEN_MODULES = { + "subprocess", "os.system", "pty", "socket", # System access + "pickle", "marshal", # Serialization attacks + "__import__", # Dynamic imports + } + + FORBIDDEN_BUILTINS = { + "exec", "eval", "compile", # Meta-execution + "open", "__import__", # File/module access + } + + def validate_code(self, code: str) -> list[str]: + """Check code for dangerous patterns.""" + import ast + + issues = [] + tree = ast.parse(code) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in self.FORBIDDEN_MODULES: + issues.append(f"Forbidden import: {alias.name}") + + elif isinstance(node, ast.ImportFrom): + if node.module in self.FORBIDDEN_MODULES: + issues.append(f"Forbidden import: {node.module}") + + return issues +``` + +### Resource Limits + +```python +@dataclass +class ExecutionLimits: + """Resource limits for code execution.""" + + max_execution_time_ms: int = 30000 # 30 seconds + max_memory_mb: int = 512 + max_output_size_bytes: int = 1_000_000 # 1 MB + max_objects_created: int = 1000 +``` + +### Authentication (for HTTP transport) + +```python +# OAuth 2.1 resource server implementation +from mcp.server.auth import ResourceServer + +auth = ResourceServer( + issuer="https://auth.example.com", + audience="freecad-mcp", +) + +@mcp.middleware +async def auth_middleware(request, call_next): + """Validate OAuth tokens for HTTP transport.""" + if mcp.transport_type == "http": + await auth.validate_request(request) + return await call_next(request) +``` + +--- + +## Deployment Modes + +### Mode 1: XML-RPC Mode (Recommended) + +Connects to FreeCAD via XML-RPC protocol. Works on all platforms (macOS, Linux, Windows). + +```yaml +# .mise.toml +[tools] +python = "3.11" # Required for FreeCAD ABI compatibility +uv = "latest" + +[env] +FREECAD_MODE = "xmlrpc" +FREECAD_XMLRPC_PORT = "9875" +``` + +**Setup:** + +1. Run `just install-bridge-macro` to install the StartMCPBridge macro +1. Start FreeCAD and execute the macro: Macro → Macros → StartMCPBridge → Execute +1. The macro starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers + +### Mode 2: Local Embedded (Linux Only) + +Runs FreeCAD in-process for fastest execution. **Only works on Linux** - crashes on macOS/Windows due to `@rpath/libpython3.11.dylib` conflicts. + +```yaml +# .mise.toml +[tools] +python = "3.11" # Must match FreeCAD's bundled Python version +uv = "latest" + +[env] +FREECAD_MODE = "embedded" +FREECAD_PATH = "/usr/lib/freecad/lib" # Linux only +``` + +### Mode 3: Local GUI with Plugin + +1. Install FreeCAD plugin: + + ```bash + just install-freecad-plugin + ``` + +1. Configure MCP server: + + ```yaml + FREECAD_MODE = "socket" + FREECAD_SOCKET_HOST = "localhost" + FREECAD_SOCKET_PORT = "9876" + ``` + +### Mode 4: Remote/Docker + +```dockerfile +FROM freecad/freecad:latest + +# Install MCP plugin +COPY freecad_plugin /root/.FreeCAD/Mod/MCPBridge + +# Expose socket port +EXPOSE 9876 + +CMD ["freecadcmd", "-c", "from freecad_mcp.plugin import start; start()"] +``` + +--- + +## Error Handling + +### Error Categories + +```python +class MCPError(Exception): + """Base MCP error.""" + pass + +class ConnectionError(MCPError): + """Failed to connect to FreeCAD.""" + pass + +class ExecutionError(MCPError): + """Python code execution failed.""" + pass + +class TimeoutError(MCPError): + """Execution timed out.""" + pass + +class ValidationError(MCPError): + """Input validation failed.""" + pass + +class ResourceNotFoundError(MCPError): + """Requested resource does not exist.""" + pass +``` + +### Error Response Format + +```json +{ + "success": false, + "error": { + "type": "ExecutionError", + "message": "NameError: name 'foo' is not defined", + "traceback": "Traceback (most recent call last):\n File \"\", line 1, in \nNameError: name 'foo' is not defined", + "context": { + "code_snippet": "foo.bar()", + "line_number": 1 + } + } +} +``` + +--- + +## Future Extensibility + +### Planned Features + +1. **Streaming Execution Output**: Real-time stdout/stderr streaming for long operations +1. **View Manipulation**: Camera control, section views, rendering in GUI mode +1. **Parametric Updates**: Modify parameters and observe model updates +1. **Assembly Support**: Integration with Assembly3/4 workbenches +1. **FEM Integration**: Finite Element Analysis setup and results +1. **Path/CAM Support**: CAM toolpath generation and simulation +1. **Multi-Document**: Work across multiple documents simultaneously +1. **Undo/Redo**: Transaction management with rollback capability + +### Extension Points + +```python +# Plugin system for custom tools +class ToolPlugin(Protocol): + """Protocol for tool plugins.""" + + @property + def name(self) -> str: ... + + @property + def tools(self) -> list[Callable]: ... + + async def initialize(self, bridge: FreecadBridge) -> None: ... + +# Register custom plugins +mcp.register_plugin(MyCustomToolPlugin()) +``` + +--- + +## Summary + +This architecture provides: + +1. **Full Python Console Access**: Execute any FreeCAD Python code +1. **Dual Mode Support**: Works with GUI and headless FreeCAD +1. **Comprehensive Tooling**: Document, object, geometry, export tools +1. **Rich Resources**: Query documents, objects, console, workbenches +1. **Secure Execution**: Sandboxing, timeouts, resource limits +1. **Flexible Deployment**: Embedded, socket, remote modes +1. **Extensible Design**: Plugin system for custom functionality + +The MCP server acts as a powerful bridge enabling AI assistants to become intelligent FreeCAD development partners. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cb7544d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,336 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + + +## [Unreleased] + +### Added + +- **GitHub Actions CI/CD workflows**: + - `pre-commit.yaml`: Runs all pre-commit hooks on every push and PR + - `test.yaml`: Runs unit tests when MCP source files change, integration tests on main branch + - `docker.yaml`: Builds multi-arch Docker images, pushes to GHCR on main/tags + - `docker-release.yaml`: Builds and pushes to Docker Hub on semantic version tags (v1.2.3) + - Multi-arch support (linux/amd64, linux/arm64) + - Semantic version tags: `1.2.3`, `1.2`, `1`, and `latest` for stable releases + - Prerelease tags (v1.2.3-beta) only get the full version tag + - SBOM and provenance attestations for supply chain security + - Automatic Docker Hub README sync + - `pypi-release.yaml`: Builds and publishes to PyPI on semantic version tags + - Validates version in pyproject.toml matches git tag + - Tests installation on Ubuntu and macOS before publishing + - Stable releases (v1.2.3) go to PyPI, prereleases (v1.2.3-beta) to TestPyPI + - Trusted publishing with OIDC (no API tokens needed) + - Attaches wheel and sdist to GitHub Release + - `macro-test.yaml`: Tests FreeCAD macros when macro files change (validates syntax, runs tests) + - Integration tests start FreeCAD headless with MCP bridge automatically +- **GitHub issue templates**: + - Bug report template with structured fields for connection mode, FreeCAD version, OS, logs + - Feature request template with impact assessment and use case sections + - Issue template config with links to documentation and discussions +- **Dependabot configuration**: + - Weekly updates for Python dependencies, GitHub Actions, and Docker base images + - Grouped updates for development dependencies +- **PyPI package name**: `freecad-robust-mcp` (due to existing `freecad-mcp` on PyPI) + - Install via: `pip install freecad-robust-mcp` + - CLI command remains `freecad-mcp` + - Python import remains `import freecad_mcp` +- **Enhanced PyPI and Docker Hub metadata**: + - Updated pyproject.toml with expanded classifiers, keywords, and URLs for better discoverability + - Added maintainers, Changelog URL, and Docker Hub URL to project metadata + - Added `py.typed` marker file for PEP 561 typed package support + - Enhanced Dockerfile with full OCI image labels (vendor, authors, documentation, base image) + - Added `twine` and `build` to dev dependencies for package validation +- **Docker integration test workflow** (`just docker::test`): + - Builds the Docker image with a `:test` tag + - Automatically starts FreeCAD headless with MCP bridge if not already running + - Runs containerized MCP server and verifies communication + - Confirms response is from Docker container (Linux OS, Docker detection) + - Displays container hostname and raw MCP responses for debugging + - Automatically cleans up started FreeCAD process on completion +- **New MCP tool `get_mcp_server_environment`**: + - Returns environment info about where the MCP server is running + - Includes: hostname, os_name, os_version, platform, python_version + - Docker detection: `in_docker` boolean and `docker_container_id` (first 12 chars) + - Environment variables: FREECAD_MODE, FREECAD_SOCKET_HOST/PORT, FREECAD_XMLRPC_PORT + - Useful for verifying which MCP server instance you're connected to + - Documented in README.md, CLAUDE.md, and docs/MCP_TOOLS_REFERENCE.md +- **Docker support for MCP server**: + - New `Dockerfile` with BuildKit optimizations and multi-stage build + - Multi-arch support via `docker buildx` (linux/amd64, linux/arm64) + - Cache mounts for apt, pip, and uv for faster rebuilds + - Non-root user for security + - Health check for container orchestration + - Environment variables for connection configuration (FREECAD_MODE, FREECAD_SOCKET_HOST, etc.) + - `.dockerignore` to exclude development files from build context + - Docker just commands: `just docker::build`, `just docker::build-multi`, `just docker::build-push` +- **Modular justfile organization**: + - Reorganized justfile into modules in `just/` directory for better organization + - `just/docker.just` - Docker build and run commands + - `just/quality.just` - Code quality and linting commands + - `just/testing.just` - Test execution commands + - `just/freecad.just` - FreeCAD plugin and macro commands + - `just/documentation.just` - Documentation building commands + - All existing shortcut commands preserved (e.g., `just test`, `just lint`, `just check`) + - New module syntax available: `just docker::build`, `just quality::secrets`, etc. + - Use `just --list ` to see commands in a specific module +- **Improved cut face detection for curved objects in CutObjectForMagnets macro**: + - `_find_cut_face_name()` now uses a two-strategy approach: + - Strategy 1: Match faces by normal direction (works for flat/planar objects) + - Strategy 2: Match faces by proximity to cut plane (works for curved objects like vases) + - For curved objects where no face has a matching normal (dot product < 0.99), the function + now finds faces whose center lies closest to the cut plane + - This fixes the "Could not find cut face" error when cutting vases and other curved shapes at angles + - Tested successfully with a vase model cut at 30° angle using a datum plane + +- **Undo support for CutObjectForMagnets macro**: + - Each major step is now wrapped in a FreeCAD transaction + - Users can undo individual steps via Edit → Undo in the GUI + - Transaction steps: Create Bottom Body, Create Top Body, Create Bottom Hole Sketch, Create Top Hole Sketch, Create Bottom Magnet Holes, Create Top Magnet Holes, Hide Original Object + - If any step fails, the transaction is aborted to prevent partial state +- **VS Code workspace configuration**: + - Added `.vscode/settings.json` with cSpell configuration matching codespell pre-commit + - Custom dictionary includes all project-specific terms from `.codespell-ignore-words.txt` + - Configured Ruff as default Python formatter with format-on-save + - Added `.vscode/extensions.json` recommending Code Spell Checker, Ruff, Pylance, etc. +- **Integration tests for CutObjectForMagnets macro**: + - `test_cut_solid_box_with_partdesign_holes` - Tests cutting solid objects with PartDesign::Hole features + - `test_cut_hollow_cylinder_with_partdesign_holes` - Tests cutting hollow objects (vase-shaped) with PartDesign::Hole + - `test_boolean_hole_creation_fallback` - Tests the `_create_holes_boolean` fallback method + - `test_boolean_vs_partdesign_comparison` - Compares output of both hole creation methods + - `test_single_hole` / `test_many_holes` - Edge case tests for hole count variations + - Tests are in `tests/integration/test_cut_object_for_magnets.py` and run via `just test-integration` +- **Status bar display for MCP bridge in GUI mode**: + - Shows "🔌 MCP Bridge running (XML-RPC:9875) | waiting for connections..." when idle + - Shows "🔌 MCP Bridge active (XML-RPC:9875) | X requests | last: Ys ago" after activity + - Updates every 5 seconds to show current status + - Automatically clears when bridge is stopped +- Initial MCP server implementation for FreeCAD integration +- Bridge architecture supporting multiple connection modes: + - Embedded mode (headless FreeCAD) + - Socket mode (JSON-RPC) + - XML-RPC mode (neka-nat compatible) +- MCP tools for FreeCAD operations: + - Document management (create, open, save, close) + - Object manipulation (create, modify, delete) + - PartDesign workflow (sketches, pads, pockets, fillets) + - Import/export (STEP, STL, 3MF, OBJ, IGES) + - Macro management + - View and screenshot capture +- `export_3mf` - Export objects to 3MF format (modern 3D printing format with color/material support) +- Additional primitive creation tools: + - `create_cone` - Create cone primitives with configurable radii + - `create_torus` - Create torus (donut) primitives + - `create_wedge` - Create wedge/ramp primitives + - `create_helix` - Create helix curves for threads and springs +- Transform and manipulation tools: + - `scale_object` - Scale objects uniformly or non-uniformly + - `rotate_object` - Rotate objects around arbitrary axes + - `copy_object` - Create copies with optional offset + - `mirror_object` - Mirror objects across planes (XY, XZ, YZ) +- Selection management tools: + - `get_selection` - Get current selection with sub-elements + - `set_selection` - Programmatically select objects + - `clear_selection` - Clear all selections +- Advanced PartDesign tools: + - `revolution_sketch` - Create revolution solids from sketches + - `groove_sketch` - Create subtractive revolutions + - `create_hole` - Create parametric holes with threading support (ISO, UNC, UNF) + - `linear_pattern` - Create linear patterns of features + - `polar_pattern` - Create circular patterns of features + - `mirrored_feature` - Mirror PartDesign features + - `loft_sketches` - Create lofts through multiple profiles + - `sweep_sketch` - Sweep profiles along paths +- Additional sketch geometry tools: + - `add_sketch_line` - Add lines with construction mode option + - `add_sketch_arc` - Add arcs with center/radius/angles + - `add_sketch_point` - Add points for hole centers +- View and document control tools: + - `zoom_in` / `zoom_out` - View zoom controls + - `set_camera_position` - Set camera position and look-at point + - `undo` / `redo` - Document undo/redo operations + - `get_undo_redo_status` - Query undo/redo state + - `recompute` - Force document recomputation + - `get_console_log` - Get console messages, warnings, errors +- Parts library tools: + - `list_parts_library` - List available library parts + - `insert_part_from_library` - Insert parts from library with positioning +- MCP resources exposing FreeCAD state: + - Version and status information + - Document and object listings + - Workbench information + - Console output +- MCP prompts for guided workflows: + - Part design assistance + - Export/import guidance + - Shape analysis + - Macro development help + - Troubleshooting guides +- Comprehensive pre-commit hook configuration: + - Ruff for linting and formatting + - MyPy for type checking + - Bandit for security scanning + - Multi-layer secrets detection (Gitleaks, detect-secrets, TruffleHog) + - Markdownlint and mdformat for documentation + - Codespell for spell checking +- Project documentation: + - CLAUDE.md with AI assistant guidelines + - ARCHITECTURE.md with system design + - README.md with usage instructions + +### Changed + +- **Reorganized README.md structure**: + - Split into "For Users" and "For Developers" top-level sections + - Each section has MCP Server and FreeCAD Macros subsections + - Added Docker installation and Claude Code configuration for containerized usage + - pip installation now shown as primary option for users + - mise/just shown as alternative for developers +- **Improved docs/index.md landing page**: + - Now properly links to all documentation files in the repo + - Added documentation table with descriptions + - Updated quick start, features, and connection modes sections +- CLAUDE.md now documents `uv` package manager usage and `uv run` requirement for Python tools +- Added proper MIT license attribution to files inspired by neka-nat/freecad-mcp: + - `src/freecad_mcp/bridge/xmlrpc.py` - XML-RPC protocol design + - `src/freecad_mcp/freecad_plugin/server.py` - Queue-based thread safety pattern +- **Improved error messaging for connection failures**: + - XML-RPC bridge now provides detailed instructions when FreeCAD plugin is not running + - Error message explains how to start the MCP bridge in FreeCAD + - References the new `just install-macro` command for easy setup +- **New justfile commands for FreeCAD integration**: + - `just install-macro` - Installs StartMCPBridge.FCMacro with dynamic project paths + - `just uninstall-macro` - Removes the installed macro + - `just install-freecad-plugin` - Installs full plugin to FreeCAD Mod directory + - `just uninstall-freecad-plugin` - Removes the installed plugin + - `just run-headless` - Launches FreeCAD in headless mode with MCP bridge + - `just run-headless-custom ` - Launch with custom FreeCADCmd path + - `just run-gui` - Launches FreeCAD GUI with MCP bridge auto-started + - `just run-gui-custom ` - Launch GUI with custom FreeCAD path + - Supports macOS, Linux, and Windows paths automatically +- **Headless mode support for FreeCAD plugin**: + - Plugin now detects when Qt is unavailable and uses thread-based queue processing + - New `run_forever()` method for running in headless/console mode + - New `headless_server.py` script for easy headless startup + - Note: Screenshots and view features are not available in headless mode +- **Improved error handling for GUI-dependent tools**: + - All view tools now check `FreeCAD.GuiUp` and return clear error messages in headless mode + - Tools return `{"success": False, "error": "..."}` instead of raising exceptions + - Affected tools: `set_object_visibility`, `set_display_mode`, `set_object_color`, + `zoom_in`, `zoom_out`, `set_camera_position`, `get_screenshot` + - Non-GUI tools (`undo`, `redo`, `recompute`) now also return structured error responses +- **Updated documentation for connection modes**: + - README.md now documents all three modes: `xmlrpc`, `socket`, `embedded` + - Changed default recommended mode from `embedded` to `xmlrpc` (works on all platforms) + - Added `FREECAD_XMLRPC_PORT` environment variable documentation + - Documented platform limitations (embedded mode only works on Linux) + - Updated Python version requirement from 3.12+ to 3.11 (FreeCAD ABI compatibility) + - ARCHITECTURE.md deployment modes updated to reflect current implementation + +### Fixed + +- **Justfile module working directory issue**: + - Fixed `just freecad::run-headless` and other FreeCAD commands failing with wrong path + - Issue: `$(pwd)` in module recipes returned `just/` directory instead of project root + - Solution: All module files now use `project_root := justfile_directory()` variable + - Added documentation in CLAUDE.md about justfile working directory behavior in modules +- **Cut face detection for re-cutting already-cut objects in CutObjectForMagnets macro**: + - When cutting an object that was previously cut, the macro now correctly identifies the NEW cut face + - Previously, the algorithm only checked face normals but not proximity to the cut plane + - This caused it to potentially select an OLD cut face from a previous operation + - New algorithm uses three strategies with combined normal + distance checks: + 1. Planar faces with exact normal match AND close to cut plane (ideal case) + 2. Any faces with good normal match close to cut plane (for curved objects) + 3. Fallback to best normal match with warning (for edge cases) +- **Sketch attachment failure in CutObjectForMagnets macro**: + - Changed `_find_cut_face_name()` to use `body.BaseFeature` instead of `body.Tip` + - Changed `_create_hole_sketch()` to attach sketches to BaseFeature instead of Tip + - Root cause: When re-running the macro on a failed state, `body.Tip` was the broken + `MagnetHoles` feature (with 0 faces) instead of the solid `BaseFeature` + - This caused the macro to select non-planar faces (Cone, Toroid) instead of the + actual planar cut face, resulting in "Invalid" sketch state with `FlatFace` MapMode + - Now correctly finds Face4 (the planar cut face with dot=1.0) instead of Face81 (Cone) +- **Python version mismatch crash** - Embedded FreeCAD mode was crashing with SIGSEGV + - FreeCAD bundles `libpython3.11.dylib` and requires Python 3.11 for ABI compatibility + - Using Python 3.12+ caused `EXC_BAD_ACCESS` when loading FreeCAD.so (calling `PySys_GetObject` from wrong Python version) + - Changed `.mise.toml` to use Python 3.11 and updated `pyproject.toml` accordingly +- **Embedded mode crashes on macOS** - Even with correct Python version, embedded mode fails + - FreeCAD's `FreeCAD.so` links to `@rpath/libpython3.11.dylib` which conflicts with external Python interpreters + - Documented that embedded mode only works on Linux; macOS/Windows must use `xmlrpc` or `socket` mode + - Updated `.mcp.json` to use `xmlrpc` mode by default + - Added FreeCAD connection modes documentation to CLAUDE.md +- Server startup crash with `AttributeError: 'FastMCP' object has no attribute 'on_startup'` + - Migrated from deprecated `mcp.on_startup()`/`on_shutdown()` hooks to the modern `lifespan` async context manager pattern + - This fix is required for compatibility with MCP SDK 1.25.0+ +- Pre-commit hook compatibility issues: + - Ruff UP038: Updated isinstance calls to use modern union syntax + - MyPy: Added type ignore comments for FastMCP dynamic attributes + - Codespell: Added FreeCAD-specific terms to ignore list + - Markdownlint/mdformat: Added mdformat-simple-breaks plugin for consistent horizontal rules +- Justfile commands now use `uv run` prefix for all Python tools (pytest, ruff, mypy, etc.) +- **Headless server import error** - `just run-headless` was failing with `'freecad_mcp' is not a package` + - The `freecad_mcp/__init__.py` imports the MCP SDK which isn't available in FreeCAD's Python environment + - Fixed by importing `FreecadMCPPlugin` directly from the module file instead of through the package hierarchy + - Added explicit `flush=True` to print statements for immediate output in FreeCAD's buffered stdout +- **Headless mode queue processor not running** - XML-RPC `execute` calls would hang indefinitely + - The code was checking for Qt (PySide) availability to determine GUI vs headless mode + - PySide6 is available in FreeCAD headless mode, but Qt timers don't fire without an event loop + - Fixed by using `FreeCAD.GuiUp` to correctly detect headless mode and start the queue processor thread +- **Renamed CutWithConnectors macro to CutObjectForMagnets**: + - Macro file renamed from `CutWithConnectors.FCMacro` to `CutObjectForMagnets.FCMacro` + - Directory renamed from `Cut_with_Connectors` to `Cut_Object_for_Magnets` + - Updated class names: `CutWithConnectorsDialog` → `CutObjectForMagnetsDialog` + - Updated dialog title and UI labels to reference "magnets" instead of "connectors" + - Updated justfile commands to reference new file paths + - All functionality preserved - just a naming update for clarity +- **CutObjectForMagnets macro - hole placement improvements**: + - Changed from spacing-based to count-based hole placement (default: 6 holes) + - Holes now distributed evenly along the perimeter of the cut face + - Both halves now have identical hole positions that align when joined + - Fixed cut face detection to find the correct face at the cut plane (was finding original object faces) + - Fixed hole positioning for hollow objects (ring-shaped cross-sections) + - Safety check correctly rejects holes that would penetrate inner walls on thin-walled objects + - Added smart hole repositioning: when a hole fails the safety check, the macro tries to find a nearby safe position by: + - Moving further inward from the edge (1.5x, 2x, 2.5x, 3x inset) + - Moving along the perimeter to find a thicker section (±5%, ±10%, ±15%, ±20%) + - Hole validation now checks BOTH parts simultaneously to ensure holes align perfectly + - Added minimum hole spacing check: holes must be at least 2x diameter apart (one hole width between them) + - Automatically reduces hole count when requested holes would overlap + - Updated default values: diameter=3mm, depth=3mm, holes=6 + - Added dual clearance system with preferred and minimum values: + - "Edge Clearance (Preferred)": default 2mm - used for initial hole placement + - "Edge Clearance (Minimum)": default 0.5mm - used as fallback during repositioning + - Smart repositioning now tries clearances from preferred down to minimum before moving holes + - This keeps holes in ideal positions when possible, only moving closer to edges when necessary + - Dialog now shows body name and type being cut + - Smart plane detection: selecting both object and plane auto-sets the plane as cut plane + - Added object selection combo box: users can now change the object to cut in the dialog (not just from initial selection) +- **CutObjectForMagnets macro - PartDesign::Hole implementation (parametric holes)**: + - Holes now created as native `PartDesign::Hole` features instead of Part boolean operations + - Cut halves created as `PartDesign::Body` objects using `PartDesign::BaseFeature` + - Hole centers defined via `Sketcher::SketchObject` attached to cut face + - Benefits of new approach: + - Holes appear in the feature tree and can be edited after creation + - Change hole diameter/depth by modifying the Hole feature properties + - Sketch shows hole center points for visualization + - Full PartDesign parametric workflow integration + - New helper methods added: + - `_create_body_from_shape()` - Wraps Part::Shape in PartDesign::Body + - `_find_cut_face_name()` - Locates cut face by normal direction + - `_world_to_sketch_coords()` - Transforms world coords to sketch-local 2D + - `_create_hole_sketch()` - Creates sketch with points at hole centers + - `_create_hole_feature()` - Creates PartDesign::Hole from sketch + - Original boolean hole method kept as `_create_holes_boolean()` for potential fallback + - FreeCAD 1.0+ API compatibility fixes: + - Fixed `_create_body_from_shape()` to use `body.BaseFeature = feature` property instead of `body.newObject("PartDesign::BaseFeature")` + - Fixed sketch attachment to use `AttachmentSupport` property instead of deprecated `Support` + - Fixed `_create_hole_feature()` to use `DepthType = "Dimension"` (string) and `HoleCutType = "None"` instead of numeric values + - Tested successfully with solid objects (box) and hollow objects (vase-shaped cylinder) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6329b85 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1022 @@ +# CLAUDE.md - AI Assistant Guidelines for This Project + +## Project Overview + +This is a Python project that provides an MCP (Model Context Protocol) server for FreeCAD integration. It follows strict code quality, security, and documentation standards. + +### Critical: Python Version Must Match FreeCAD + +**CRITICAL**: This project **MUST** use the same Python version that the current stable FreeCAD release bundles internally. FreeCAD embeds a specific Python version (e.g., `libpython3.11.dylib`), and using a different Python version causes **fatal crashes** (SIGSEGV) due to ABI incompatibility. + +Before changing the Python version in `.mise.toml` or `pyproject.toml`: + +1. Check which Python version FreeCAD bundles: + - macOS: `ls /Applications/FreeCAD.app/Contents/Resources/lib/libpython*` + - Linux: `ls /usr/lib/freecad/lib/libpython*` or check FreeCAD's Python console +1. The Python minor version (e.g., 3.11) **must match exactly** +1. Using Python 3.12+ with FreeCAD that bundles Python 3.11 will crash on `import FreeCAD` + +Current requirement: **Python 3.11** (matching FreeCAD 1.0.x bundled Python) + +### FreeCAD Connection Modes + +This MCP server supports three connection modes. **Embedded mode does NOT work on macOS** due to how FreeCAD's libraries are linked. + +| Mode | Description | Platform Support | +| ---------- | ------------------------------------------- | --------------------------------- | +| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) | +| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** | +| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) | + +**Why embedded mode fails on macOS:** +FreeCAD's `FreeCAD.so` library links to `@rpath/libpython3.11.dylib` (FreeCAD's bundled Python). When you try to import it from a different Python interpreter (even the same version), it causes a crash because the Python runtime state is incompatible. + +**Recommended setup for macOS/Windows:** + +1. Use `xmlrpc` or `socket` mode in your configuration + +1. Start FreeCAD and run the MCP plugin inside FreeCAD's Python console: + + ```python + from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin + plugin = FreecadMCPPlugin() + plugin.start() + ``` + +1. The MCP server will then connect to FreeCAD over the network + +--- + +## Development Environment Setup + +### Required Tools (managed via `mise`) + +This project uses [`mise`](https://mise.jdx.dev/) for local development tool management. All tool versions are pinned in `.mise.toml`. + +```bash +# Install mise (if not already installed) +curl https://mise.run | sh + +# Install all project tools +mise install + +# Activate mise in your shell (add to .bashrc/.zshrc) +eval "$(mise activate bash)" # or zsh/fish +``` + +### Package Management (via `uv`) + +This project uses [`uv`](https://docs.astral.sh/uv/) for Python package and virtual environment management. + +**CRITICAL**: All Python tools (pytest, ruff, mypy, etc.) are installed in the virtual environment managed by `uv`. You must use `uv run` to execute them. + +```bash +# Install dependencies +uv sync --all-extras + +# Run any Python tool +uv run pytest # Run tests +uv run ruff check src # Run linter +uv run mypy src # Run type checker +uv run pre-commit run # Run pre-commit + +# Run the project +uv run freecad-mcp +``` + +**Why `uv run` is required:** + +- Tools like `pytest`, `ruff`, `mypy` are NOT installed globally +- They exist only in the project's virtual environment +- Running `pytest` directly will fail with "command not found" +- Always prefix with `uv run` when running Python tools directly + +### Workflow Commands (via `just`) + +This project uses [`just`](https://just.systems/) as a command runner. Always prefer `just` commands over raw commands. + +Commands are organized into modules for better organization: + +```bash +# List all available commands +just --list + +# List commands in a specific module +just --list docker +just --list quality +just --list testing +just --list freecad +just --list documentation + +# Common shortcut commands (aliases to module commands) +just install # Install project dependencies +just lint # Run all linters (quality::lint) +just format # Format code (quality::format) +just test # Run tests (testing::unit) +just check # Run pre-commit checks (quality::check) +just docs # Build documentation (documentation::build) +just all # Run all checks and tests + +# Module commands (use :: syntax) +just docker::build # Build Docker image for local architecture +just docker::build-multi # Build multi-arch image (amd64 + arm64) +just docker::run # Run Docker container +just quality::secrets # Run all secrets scanners +just testing::cov # Run tests with coverage +just freecad::run-gui # Run FreeCAD GUI with MCP bridge +just documentation::serve # Serve documentation locally +``` + +#### Just Module Structure + +| Module | Description | Key Commands | +| --------------- | ----------------------------------- | -------------------------------------------- | +| `docker` | Docker build and run commands | `build`, `build-multi`, `build-push`, `run` | +| `quality` | Code quality and linting | `check`, `lint`, `format`, `secrets` | +| `testing` | Test execution | `unit`, `cov`, `integration`, `all` | +| `freecad` | FreeCAD plugin and macro management | `run-gui`, `run-headless`, `install-*-macro` | +| `documentation` | Documentation building | `build`, `serve`, `open` | + +Module files are located in the `just/` directory. + +--- + +## Code Quality Standards + +### Pre-commit Hooks + +**CRITICAL**: This project uses `pre-commit` for all code quality checks. Before finishing ANY code changes: + +1. Run `just check` or `uv run pre-commit run --all-files` +1. Fix ALL issues reported +1. Re-run until all checks pass + +Pre-commit runs these checks: + +- **Ruff**: Linting and import sorting (replaces flake8, isort, pyupgrade) +- **Ruff Format**: Code formatting (replaces black) +- **MyPy**: Static type checking +- **Bandit**: Security vulnerability scanning +- **Safety**: Dependency vulnerability checking +- **Codespell**: Spell checking in code and docs +- **YAML/TOML/JSON validation**: Config file validation +- **Trailing whitespace and EOF fixes**: File hygiene + +### Linting Rules + +- Follow PEP 8 style guidelines +- Use type hints for ALL function signatures +- Maximum line length: 88 characters (ruff/black default) +- Use modern Python syntax (3.10+ features encouraged) + +### Security Scanning + +- Bandit scans for common security issues +- Never commit secrets, API keys, or credentials +- Use environment variables for sensitive configuration +- Safety checks dependencies for known vulnerabilities + +### Secrets Detection (Multi-Layer) + +This project uses a comprehensive, multi-layer approach to secrets detection: + +| Tool | Purpose | Config File | +| ------------------ | -------------------------------------------------- | ------------------- | +| **Gitleaks** | Fast regex-based scanning of files and git history | `.gitleaks.toml` | +| **detect-secrets** | Baseline tracking for known/approved secrets | `.secrets.baseline` | +| **TruffleHog** | Verified secrets (actually tests if they work) | CLI args | + +```bash +# Run all secrets scanners +just secrets + +# Individual scanners +just secrets-gitleaks # Fast pattern matching +just secrets-gitleaks-history # Scan git history +just secrets-detect # Check against baseline +just secrets-audit # Interactive baseline audit +just secrets-trufflehog # Verified secrets only +``` + +**Managing False Positives:** + +1. **Gitleaks**: Add patterns to `.gitleaks.toml` allowlist section +1. **detect-secrets**: Run `just secrets-audit` to mark false positives in baseline +1. **TruffleHog**: Uses `--only-verified` to minimize false positives + +### Markdown Linting + +All markdown files are linted and formatted for consistency: + +```bash +just markdown-lint # Check markdown files +just markdown-fix # Auto-fix markdown issues +just markdown-format # Format with mdformat +``` + +Configuration: `.markdownlint.yaml` + +--- + +## Documentation Requirements + +### Code Documentation + +**ALL code must be documented** for auto-documentation building: + +1. **Module docstrings**: Every Python file must have a module-level docstring explaining its purpose + +1. **Class docstrings**: Use Google-style or NumPy-style docstrings + + ```python + class Example: + """Short description of the class. + + Longer description if needed, explaining the purpose + and usage of this class. + + Attributes: + name: Description of the name attribute. + value: Description of the value attribute. + + Example: + >>> obj = Example("test", 42) + >>> obj.process() + """ + ``` + +1. **Function/Method docstrings**: Document all public functions + + ```python + def calculate_total(items: list[Item], tax_rate: float = 0.0) -> Decimal: + """Calculate the total price including optional tax. + + Args: + items: List of Item objects to sum. + tax_rate: Tax rate as a decimal (e.g., 0.08 for 8%). + + Returns: + The total price as a Decimal, including tax. + + Raises: + ValueError: If tax_rate is negative. + + Example: + >>> items = [Item(price=10), Item(price=20)] + >>> calculate_total(items, tax_rate=0.1) + Decimal('33.00') + """ + ``` + +1. **Inline comments**: Use sparingly, only for complex logic + +### Documentation Building + +Documentation is auto-generated using the docstrings. Run: + +```bash +just docs # Build documentation +just docs-serve # Serve documentation locally +``` + +--- + +## Testing Requirements + +### Test Coverage + +**ALL code must have tests**. Create tests for: + +1. **Unit tests**: Test individual functions and methods +1. **Integration tests**: Test component interactions +1. **Edge cases**: Test boundary conditions and error handling +1. **Regression tests**: Add tests when fixing bugs + +### Test Structure + +```text +tests/ +├── __init__.py +├── conftest.py # Shared fixtures +├── unit/ # Unit tests +│ ├── __init__.py +│ └── test_*.py +├── integration/ # Integration tests +│ ├── __init__.py +│ └── test_*.py +└── fixtures/ # Test data files +``` + +### Writing Tests + +- Use `pytest` as the test framework +- Use descriptive test names: `test_calculate_total_with_empty_list_returns_zero` +- Use fixtures for common test data +- Use parametrize for testing multiple inputs +- Aim for high coverage but prioritize meaningful tests + +```python +import pytest +from myproject import calculate_total + +class TestCalculateTotal: + """Tests for the calculate_total function.""" + + def test_empty_list_returns_zero(self): + """Empty item list should return zero total.""" + assert calculate_total([]) == Decimal("0") + + @pytest.mark.parametrize("tax_rate,expected", [ + (0.0, Decimal("100")), + (0.1, Decimal("110")), + ]) + def test_tax_calculation(self, sample_items, tax_rate, expected): + """Tax should be correctly applied to total.""" + result = calculate_total(sample_items, tax_rate=tax_rate) + assert result == expected +``` + +### Running Tests + +```bash +just test # Run all tests +just test-cov # Run tests with coverage report +just test-fast # Run tests without slow markers +uv run pytest tests/unit/ # Run specific test directory +uv run pytest -k "test_name" # Run specific test by name +``` + +--- + +## Workflow for Code Changes + +### Before Making Changes + +1. Ensure you're on the latest code +1. Run `just install` to update dependencies +1. Run `just all` to verify clean starting state + +### After Making Changes + +**MANDATORY CHECKLIST** - Complete ALL steps before finishing: + +1. [ ] **Add/update docstrings** for all new/modified code +1. [ ] **Add/update tests** for all new/modified functionality +1. [ ] **Run formatting**: `just format` +1. [ ] **Run linting**: `just lint` - fix ALL issues +1. [ ] **Run type checking**: `just typecheck` - fix ALL issues +1. [ ] **Run security checks**: `just security` - fix ALL issues +1. [ ] **Run tests**: `just test` - ALL tests must pass +1. [ ] **Run pre-commit**: `just check` - ALL checks must pass + +### Quick Verification Command + +```bash +# Run everything at once - must complete with no errors +just all +``` + +### Running Pre-commit on Specific Files + +**IMPORTANT**: After editing any files, always run pre-commit hooks on those specific files before considering the task complete: + +```bash +# Run pre-commit on specific files you edited +uv run pre-commit run --files path/to/file1.py path/to/file2.py + +# Or run on all files (slower but comprehensive) +uv run pre-commit run --all-files +``` + +This catches issues early and ensures code quality standards are met. Never skip this step - fix all reported issues before finishing. + +--- + +## Library and Tool Versions + +### Version Policy + +- **Always use the most recent stable releases** of all libraries and tools +- Pin exact versions in `pyproject.toml` for reproducibility +- Regularly update dependencies with `just update-deps` +- Check for security vulnerabilities with `just security` + +### Core Dependencies + +Keep these tools at their latest stable versions: + +- Python: **3.11** (must match FreeCAD's bundled Python - see "Critical: Python Version Must Match FreeCAD" above) +- Ruff: Latest stable +- MyPy: Latest stable +- Pytest: Latest stable +- Pre-commit: Latest stable + +--- + +## File Structure Conventions + +```text +project-root/ +├── .mise.toml # Tool version management +├── .pre-commit-config.yaml # Pre-commit hook configuration +├── justfile # Main task runner (imports modules) +├── just/ # Just module files +│ ├── docker.just # Docker build/run commands +│ ├── quality.just # Code quality commands +│ ├── testing.just # Test commands +│ ├── freecad.just # FreeCAD plugin/macro commands +│ └── documentation.just # Documentation commands +├── pyproject.toml # Project configuration and dependencies +├── Dockerfile # Docker image definition +├── src/ +│ └── package_name/ # Main package source +│ ├── __init__.py +│ └── *.py +├── tests/ # Test files +├── docs/ # Documentation source +└── CLAUDE.md # This file +``` + +--- + +## File Extension Conventions + +**Use full file extensions, not DOS-style shortened versions:** + +| Correct Extension | Incorrect (DOS-style) | +| ----------------- | --------------------- | +| `.yaml` | `.yml` | +| `.jpeg` | `.jpg` | +| `.html` | `.htm` | +| `.conf` | (varies) | + +This applies to: + +- GitHub workflow files: `.github/workflows/*.yaml` +- GitHub issue templates: `.github/ISSUE_TEMPLATE/*.yaml` +- Dependabot config: `.github/dependabot.yaml` +- MkDocs config: `mkdocs.yaml` +- Any other YAML, JPEG, or similar files + +When creating new files, always use the full extension. + +--- + +## Justfile HEREDOC Syntax + +**CRITICAL**: When writing heredocs in justfile recipes, the content must be indented to match the recipe body. Just parses non-indented lines as justfile syntax, which causes errors with Python code containing dots (e.g., `sys.path`). + +### Correct Pattern + +```just +# Recipe with heredoc - content MUST be indented +my-recipe: + #!/usr/bin/env bash + cat > "$FILE" << EOF + # Python code goes here - indented with spaces + import sys + if project_path not in sys.path: + sys.path.insert(0, project_path) + EOF +``` + +### Incorrect Pattern (Will Fail) + +```just +# This FAILS - just interprets sys.path as justfile syntax +my-recipe: + #!/usr/bin/env bash + cat > "$FILE" << EOF +import sys +if project_path not in sys.path: # ERROR: Unknown start of token '.' + sys.path.insert(0, project_path) +EOF +``` + +### Key Rules + +1. **Indent heredoc content**: All lines inside the heredoc must be indented (4 spaces typically) +1. **Indent the EOF marker**: The closing `EOF` must also be indented to match +1. **Use `\\n` for newlines**: In heredoc strings that need literal `\n`, use `\\n` +1. **Variable expansion**: `${VAR}` works inside heredocs for bash variables + +See the recipes in `just/freecad.just` (e.g., `install-bridge-macro`, `run-gui`, `run-headless`) for working examples. + +--- + +## Justfile Working Directory in Modules + +**CRITICAL**: When writing recipes in just modules (files in the `just/` directory), be aware that the working directory behavior can be surprising. + +### The Problem + +When you use `$(pwd)` in a module recipe, it returns the **module's directory** (e.g., `/path/to/project/just/`), NOT the project root. This causes path errors like: + +```text +Exception while processing file: /path/to/project/just/src/freecad_mcp/... +``` + +The path incorrectly includes `just/` because `$(pwd)` returned the module directory. + +### The Solution + +Use `justfile_directory()` to get the project root. In modules, this function returns the directory of the **main justfile** (project root), not the module file's directory. + +```just +# At the top of your module file (e.g., just/freecad.just) +project_root := justfile_directory() + +# In recipes, use the variable instead of $(pwd) +my-recipe: + #!/usr/bin/env bash + PROJECT_DIR="{{project_root}}" # Correct: /path/to/project + # NOT: PROJECT_DIR="$(pwd)" # Wrong: /path/to/project/just +``` + +### Key Points + +1. **`$(pwd)` in modules**: Returns the module's directory (`just/`), not project root +1. **`justfile_directory()` in modules**: Returns the main justfile's directory (project root) +1. **Always define `project_root`**: Add `project_root := justfile_directory()` at the top of module files +1. **Use `{{project_root}}`**: Reference paths relative to the project root using this variable + +### Example Fix + +Before (broken): + +```just +run-headless: + #!/usr/bin/env bash + PROJECT_DIR="$(pwd)" # Returns /path/to/project/just/ - WRONG! + SCRIPT="${PROJECT_DIR}/src/script.py" # /path/to/project/just/src/script.py +``` + +After (correct): + +```just +project_root := justfile_directory() + +run-headless: + #!/usr/bin/env bash + PROJECT_DIR="{{project_root}}" # Returns /path/to/project/ - CORRECT! + SCRIPT="${PROJECT_DIR}/src/script.py" # /path/to/project/src/script.py +``` + +--- + +## FreeCAD GUI vs Headless Mode + +FreeCAD can run in two modes: + +1. **GUI mode**: Full graphical interface with 3D view, accessed via `FreeCAD.app` or `freecad` +1. **Headless mode**: Console-only, no GUI, accessed via `FreeCADCmd` or `freecadcmd` + +### Detecting GUI Availability + +**CRITICAL**: Always use `FreeCAD.GuiUp` to check if the GUI is available. **Never** check for Qt/PySide availability as a proxy for GUI mode. + +```python +if FreeCAD.GuiUp: + # GUI is available - can use FreeCADGui, ViewObjects, screenshots + import FreeCADGui + view = FreeCADGui.ActiveDocument.ActiveView +else: + # Headless mode - no GUI features available + pass +``` + +### Why NOT to Check for PySide/Qt + +**WRONG** - Do not use Qt availability to detect GUI mode: + +```python +# WRONG - PySide is available even in headless mode! +try: + from PySide6 import QtCore + # This will be True even in headless mode + is_gui_mode = True +except ImportError: + is_gui_mode = False +``` + +**RIGHT** - Use `FreeCAD.GuiUp`: + +```python +# CORRECT - GuiUp is False in headless mode +is_gui_mode = FreeCAD.GuiUp +``` + +**Why this matters**: FreeCAD bundles PySide6 even in headless mode (`freecadcmd`), but without a running Qt event loop: + +- Qt timers (`QTimer`) will never fire +- GUI widgets cannot be created or displayed +- Any code relying on Qt events will hang indefinitely + +This caused a bug where the MCP bridge queue processor used a `QTimer` in headless mode, resulting in `execute` calls hanging forever because the timer callbacks never ran. + +### GUI-Only Features + +These features **only work in GUI mode** and will fail or crash in headless mode: + +| Feature | Requires GUI | Alternative in Headless | +| ------------------- | ------------ | ----------------------- | +| `FreeCADGui` module | Yes | Not available | +| `obj.ViewObject` | Yes | Returns `None` | +| Screenshots | Yes | Not available | +| Camera/view control | Yes | Not available | +| Display mode/color | Yes | Not available | +| Object visibility | Yes | Not available | +| Zoom in/out | Yes | Not available | + +### Implementing GUI-Safe Tools + +**CRITICAL**: All MCP tools that use GUI features must check `FreeCAD.GuiUp` and return a structured error response instead of crashing. + +#### Pattern for GUI-Safe Tools + +```python +@mcp.tool() +async def set_object_visibility(object_name: str, visible: bool) -> dict[str, Any]: + """Set object visibility. Requires GUI mode.""" + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - visibility cannot be set in headless mode"}} +else: + doc = FreeCAD.ActiveDocument + obj = doc.getObject({object_name!r}) + if obj is None: + _result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}} + elif hasattr(obj, "ViewObject") and obj.ViewObject: + obj.ViewObject.Visibility = {visible} + _result_ = {{"success": True, "visible": {visible}}} + else: + _result_ = {{"success": False, "error": "Object has no ViewObject"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return {{"success": False, "error": result.error_traceback or "Operation failed"}} +``` + +#### Key Requirements + +1. **Check `FreeCAD.GuiUp` first**: Before accessing any GUI features +1. **Return structured errors**: Use `{"success": False, "error": "..."}` format +1. **Never raise exceptions**: Return error dicts instead of raising `ValueError` +1. **Document GUI requirement**: Add "Requires GUI mode" to docstrings + +### Tools Updated for GUI Safety + +The following tools in `src/freecad_mcp/tools/view.py` check `FreeCAD.GuiUp`: + +- `set_object_visibility` +- `set_display_mode` +- `set_object_color` +- `zoom_in` / `zoom_out` +- `set_camera_position` +- `get_screenshot` + +### Running FreeCAD in Each Mode + +```bash +# GUI mode - with MCP bridge auto-started +just run-gui + +# Headless mode - console only +just run-headless + +# Or manually: +# GUI: /Applications/FreeCAD.app (macOS) or freecad (Linux) +# Headless: FreeCADCmd or freecadcmd +``` + +### FreeCAD Plugin Import Restrictions + +**CRITICAL**: Code running inside FreeCAD's Python environment cannot import packages that aren't available in FreeCAD's bundled Python (like `mcp`, `pydantic`, etc.). + +The `headless_server.py` script imports the plugin directly from the module file to avoid triggering the `mcp` import in `freecad_mcp/__init__.py`: + +```python +# WRONG - triggers mcp import via freecad_mcp/__init__.py +from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin + +# CORRECT - import directly from the module file +script_dir = str(Path(__file__).resolve().parent) +sys.path.insert(0, script_dir) +from server import FreecadMCPPlugin # Direct module import +``` + +This pattern is required because: + +1. `freecad_mcp/__init__.py` imports `from freecad_mcp.server import mcp` +1. The MCP SDK is installed in the project's virtualenv, not in FreeCAD's Python +1. Python processes parent package `__init__.py` files when importing nested modules + +--- + +## Common Issues and Solutions + +### Pre-commit Failing + +```bash +# Update pre-commit hooks +uv run pre-commit autoupdate + +# Clear pre-commit cache +uv run pre-commit clean + +# Run specific hook for debugging +uv run pre-commit run --all-files +``` + +### Type Checking Errors + +- Ensure all function parameters and return types have type hints +- Use `typing` module for complex types +- Add `# type: ignore[error-code]` only as last resort with explanation + +### Test Failures + +- Read the full error message and traceback +- Check if fixtures are properly defined +- Verify test isolation (no shared state between tests) + +--- + +## Pre-commit Hook Requirements + +This section documents specific requirements for each pre-commit hook to avoid common issues. + +### Ruff Linting + +- **UP038**: Use modern union syntax for isinstance: `isinstance(obj, str | int)` NOT `isinstance(obj, (str, int))` +- **Line length**: 88 characters max (but `E501` is ignored for embedded code strings) +- **Import sorting**: Ruff handles this automatically with `ruff --fix` + +### MyPy Type Checking + +This project uses relaxed mypy settings because FastMCP lacks proper type stubs. + +- **Type ignores**: When needed, use specific error codes: `# type: ignore[attr-defined]` +- **FastMCP methods**: `on_startup`, `on_shutdown`, and some `run()` args need type ignores +- **XML-RPC**: `register_function` has overly restrictive types, use `# type: ignore[arg-type]` +- **Lambda captures**: If mypy complains about union types in lambdas, assign to a local variable first + +### Codespell + +- **Technical terms**: Add legitimate technical terms to `.codespell-ignore-words.txt` +- **FreeCAD API**: "vertexes" is valid FreeCAD terminology (not "vertices") +- **Tool names**: Add tool/library names that look like typos + +### Markdownlint (MD035) + +- **Horizontal rules**: Must use `---` format (3 dashes) +- **mdformat-simple-breaks**: This plugin ensures mdformat produces `---` instead of 70 underscores +- **Conflict resolution**: markdownlint and mdformat must agree on horizontal rule style + +### Bandit Security + +These checks are intentionally skipped in `pyproject.toml`: + +- **B101**: Asserts allowed (needed for validation) +- **B102**: `exec()` required for FreeCAD Python execution +- **B110**: `try-except-pass` used for optional cleanup operations +- **B411**: XML-RPC required for FreeCAD compatibility + +### detect-secrets + +- **False positives**: Add `# pragma: allowlist secret` comment to lines with false positives +- **Baseline updates**: Run `just secrets-audit` to update `.secrets.baseline` + +### no-commit-to-branch + +- This hook fails when on `main` or `master` branch - this is expected behavior +- Always work on feature branches for actual commits + +--- + +## Changelog Maintenance + +**IMPORTANT**: Update `CHANGELOG.md` at the end of any session where files in the repository are modified. + +- Follow [Keep a Changelog](https://keepachangelog.com/) format +- Group changes under: Added, Changed, Deprecated, Removed, Fixed, Security +- Include date for each release version +- Document all significant changes, not just code changes + +--- + +## FreeCAD MCP Tools Reference + +When Claude Code is connected to the FreeCAD MCP server, the following tools are available for interacting with FreeCAD. Use these tools to control FreeCAD, create/modify objects, and debug issues. + +### Discovering Capabilities at Runtime + +The MCP server provides a `freecad://capabilities` resource that returns a complete JSON catalog of all available tools, resources, and prompts. This is the authoritative source for what's available. + +**IMPORTANT**: When adding new MCP tools or resources, you MUST also update the `freecad://capabilities` resource in `src/freecad_mcp/resources/freecad.py` to keep it in sync. The capabilities resource is defined in the `resource_capabilities()` function. + +### Execution & Debugging Tools + +| Tool | Description | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `execute_python` | Execute arbitrary Python code in FreeCAD's context. Use `_result_ = value` to return data. Has access to FreeCAD, App, Part, and all FreeCAD modules. | +| `get_console_output` | **Get recent FreeCAD console output** - useful for debugging macros and seeing error messages. Returns up to N lines of console history. | +| `get_console_log` | Alternative console log access with different formatting. | +| `get_freecad_version` | Get FreeCAD version, build date, Python version, and GUI availability. | +| `get_connection_status` | Check MCP bridge connection status, mode, and latency. | +| `get_mcp_server_environment` | Get MCP server environment info (OS, hostname, Docker detection). Useful for verifying container vs host. | + +### Document Management Tools + +| Tool | Description | +| --------------------- | ---------------------------------------------------- | +| `list_documents` | List all open FreeCAD documents. | +| `get_active_document` | Get information about the currently active document. | +| `create_document` | Create a new FreeCAD document. | +| `open_document` | Open an existing .FCStd file. | +| `save_document` | Save a document to disk. | +| `close_document` | Close a document. | +| `recompute_document` | Force recomputation of document features. | + +### Object Creation Tools (Part Workbench) + +| Tool | Description | +| ----------------- | --------------------------------------------------------------- | +| `list_objects` | List all objects in a document with their types and properties. | +| `inspect_object` | Get detailed information about a specific object. | +| `create_object` | Create a generic FreeCAD object. | +| `create_box` | Create a Part::Box primitive. | +| `create_cylinder` | Create a Part::Cylinder primitive. | +| `create_sphere` | Create a Part::Sphere primitive. | +| `create_cone` | Create a Part::Cone primitive. | +| `create_torus` | Create a Part::Torus primitive. | +| `create_wedge` | Create a Part::Wedge primitive. | +| `create_helix` | Create a Part::Helix primitive. | + +### Object Manipulation Tools + +| Tool | Description | +| ------------------- | ----------------------------------------------- | +| `edit_object` | Modify object properties. | +| `delete_object` | Delete an object from the document. | +| `boolean_operation` | Perform union, cut, or intersection operations. | +| `set_placement` | Set object position and rotation. | +| `scale_object` | Scale an object by a factor. | +| `rotate_object` | Rotate an object around an axis. | +| `copy_object` | Create a copy of an object. | +| `mirror_object` | Mirror an object across a plane. | + +### Selection Tools + +| Tool | Description | +| ----------------- | ------------------------------- | +| `get_selection` | Get currently selected objects. | +| `set_selection` | Select specific objects. | +| `clear_selection` | Clear the current selection. | + +### PartDesign Tools (Parametric Modeling) + +| Tool | Description | +| ------------------------ | --------------------------------------------- | +| `create_partdesign_body` | Create a new PartDesign::Body container. | +| `create_sketch` | Create a sketch attached to a plane or face. | +| `add_sketch_rectangle` | Add a rectangle to a sketch. | +| `add_sketch_circle` | Add a circle to a sketch. | +| `add_sketch_line` | Add a line to a sketch. | +| `add_sketch_arc` | Add an arc to a sketch. | +| `add_sketch_point` | Add a point to a sketch (for hole placement). | +| `pad_sketch` | Extrude a sketch (additive). | +| `pocket_sketch` | Cut into solid using a sketch (subtractive). | +| `revolution_sketch` | Revolve a sketch around an axis. | +| `groove_sketch` | Cut by revolving a sketch (subtractive). | +| `create_hole` | Create parametric holes from sketch points. | +| `fillet_edges` | Add fillets (rounded edges). | +| `chamfer_edges` | Add chamfers (beveled edges). | +| `loft_sketches` | Create a loft between multiple sketches. | +| `sweep_sketch` | Sweep a sketch along a path. | + +### Pattern Tools + +| Tool | Description | +| ------------------ | ------------------------------------------ | +| `linear_pattern` | Create linear pattern of features. | +| `polar_pattern` | Create polar/circular pattern of features. | +| `mirrored_feature` | Mirror a feature across a plane. | + +### View & GUI Tools (Require GUI Mode) + +| Tool | Description | +| ----------------------- | ----------------------------------------------------------- | +| `get_screenshot` | Capture a screenshot of the 3D view. **Requires GUI mode.** | +| `set_view_angle` | Set camera to standard views (front, top, isometric, etc.). | +| `fit_all` | Zoom to fit all objects in view. | +| `zoom_in` / `zoom_out` | Adjust zoom level. | +| `set_camera_position` | Set exact camera position and orientation. | +| `set_object_visibility` | Show/hide objects. **Requires GUI mode.** | +| `set_display_mode` | Set display mode (wireframe, shaded, etc.). | +| `set_object_color` | Change object colors. **Requires GUI mode.** | +| `list_workbenches` | List available FreeCAD workbenches. | +| `activate_workbench` | Switch to a different workbench. | + +### Undo/Redo Tools + +| Tool | Description | +| ---------------------- | ----------------------------------- | +| `undo` | Undo the last operation. | +| `redo` | Redo an undone operation. | +| `get_undo_redo_status` | Get available undo/redo operations. | + +### Export/Import Tools + +| Tool | Description | +| ------------- | -------------------------------------------------- | +| `export_step` | Export objects to STEP format. | +| `export_stl` | Export objects to STL format (for 3D printing). | +| `export_3mf` | Export objects to 3MF format (modern 3D printing). | +| `export_obj` | Export objects to OBJ format. | +| `export_iges` | Export objects to IGES format. | +| `import_step` | Import STEP files. | +| `import_stl` | Import STL files. | + +### Macro Tools + +| Tool | Description | +| ---------------------------- | ------------------------------- | +| `list_macros` | List available FreeCAD macros. | +| `run_macro` | Execute a macro by name. | +| `create_macro` | Create a new macro file. | +| `read_macro` | Read macro source code. | +| `delete_macro` | Delete a macro file. | +| `create_macro_from_template` | Create a macro from a template. | + +### Parts Library Tools + +| Tool | Description | +| -------------------------- | ------------------------------------------------ | +| `list_parts_library` | List available parts in FreeCAD's parts library. | +| `insert_part_from_library` | Insert a part from the library. | + +### Example: Debugging a Macro + +To debug issues with a macro running in FreeCAD: + +```python +# Get recent console output to see errors +console_output = await get_console_output(lines=50) + +# Or execute Python to check state +result = await execute_python(''' +doc = FreeCAD.ActiveDocument +if doc: + _result_ = { + "doc_name": doc.Name, + "objects": [obj.Name for obj in doc.Objects], + "errors": [obj.Name for obj in doc.Objects if hasattr(obj, 'isValid') and not obj.isValid()] + } +else: + _result_ = {"error": "No active document"} +''') +``` + +### Example: Creating a Simple Part + +```python +# Create a document +await create_document(name="MyPart") + +# Create a PartDesign body +await create_partdesign_body(name="Body") + +# Create a sketch on the XY plane +await create_sketch(body_name="Body", plane="XY_Plane", sketch_name="Sketch") + +# Add a rectangle to the sketch +await add_sketch_rectangle(sketch_name="Sketch", x=-10, y=-10, width=20, height=20) + +# Extrude the sketch +await pad_sketch(body_name="Body", sketch_name="Sketch", length=15) + +# Add fillets +await fillet_edges(body_name="Body", edges=["Edge1", "Edge2"], radius=2) + +# Export to STL +await export_stl(object_names=["Body"], file_path="/tmp/mypart.stl") +``` + +--- + +## Summary Checklist + +When working on this project, ALWAYS: + +- [ ] Use `mise` for tool management +- [ ] Use `just` commands for workflows +- [ ] Write comprehensive docstrings (Google-style) +- [ ] Write tests for all new code +- [ ] Run `just all` before finishing - everything must pass +- [ ] Use latest stable library versions +- [ ] Follow security best practices +- [ ] Update `CHANGELOG.md` with session changes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dfa4a2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,92 @@ +# syntax=docker/dockerfile:1.7 + +# FreeCAD MCP Server Dockerfile +# Multi-stage build with BuildKit optimizations for multi-arch support +# +# Build: +# docker build -t freecad-mcp . +# +# Build multi-arch: +# docker buildx build --platform linux/amd64,linux/arm64 -t freecad-mcp . +# +# Run: +# docker run --rm -i freecad-mcp + +# ============================================================================= +# Stage 1: Builder - Install dependencies and build the package +# ============================================================================= +FROM python:3.11-slim AS builder + +# Install build dependencies +# hadolint ignore=DL3008 +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Set up working directory +WORKDIR /app + +# Install uv for fast dependency management +# Using pip cache mount for faster rebuilds +# hadolint ignore=DL3013 +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-compile uv + +# Copy only dependency files first for better layer caching +COPY pyproject.toml README.md ./ +COPY src/ ./src/ + +# Create virtual environment and install dependencies +# Using uv cache mount for faster rebuilds +RUN --mount=type=cache,target=/root/.cache/uv \ + uv venv /opt/venv && \ + . /opt/venv/bin/activate && \ + uv pip install --no-compile . + +# ============================================================================= +# Stage 2: Runtime - Minimal image for running the server +# ============================================================================= +FROM python:3.11-slim AS runtime + +# Labels for container metadata (OCI Image Spec) +# Note: version, revision, and created are set dynamically in CI/CD workflows +LABEL org.opencontainers.image.title="FreeCAD MCP Server" \ + org.opencontainers.image.description="MCP (Model Context Protocol) server for FreeCAD integration with AI assistants" \ + org.opencontainers.image.url="https://github.com/spkane/freecad-mcp" \ + org.opencontainers.image.source="https://github.com/spkane/freecad-mcp" \ + org.opencontainers.image.documentation="https://github.com/spkane/freecad-mcp#readme" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.vendor="Sean P. Kane" \ + org.opencontainers.image.authors="Sean P. Kane " \ + org.opencontainers.image.base.name="python:3.11-slim" + +# Create non-root user for security +RUN groupadd --gid 1000 mcpuser && \ + useradd --uid 1000 --gid 1000 --shell /bin/bash --create-home mcpuser + +# Copy virtual environment from builder +COPY --from=builder /opt/venv /opt/venv + +# Set environment variables +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + # Default to xmlrpc mode (requires FreeCAD running externally) + FREECAD_MODE="xmlrpc" \ + FREECAD_SOCKET_HOST="host.docker.internal" \ + FREECAD_SOCKET_PORT="9876" \ + FREECAD_XMLRPC_PORT="9875" \ + FREECAD_TIMEOUT_MS="30000" + +# Switch to non-root user +USER mcpuser +WORKDIR /home/mcpuser + +# Health check - verify the server can start +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import freecad_mcp; print('ok')" || exit 1 + +# Default command - run the MCP server in stdio mode +ENTRYPOINT ["freecad-mcp"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e6a7f6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sean P. Kane (GitHub: spkane) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96f03d4 --- /dev/null +++ b/README.md @@ -0,0 +1,699 @@ +# FreeCAD Tools and MCP Server + +An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that enables integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and [FreeCAD](https://www.freecadweb.org/), allowing AI-assisted development and debugging of 3D models, macros, and workbenches. Also includes standalone FreeCAD macros for common tasks. + +## Table of Contents + + + +- [FreeCAD Tools and MCP Server](#freecad-tools-and-mcp-server) + - [Table of Contents](#table-of-contents) + - [Features](#features) + - [Requirements](#requirements) + - [For Users](#for-users) + - [MCP Server](#mcp-server) + - [Installation](#installation) + - [Using pip (recommended)](#using-pip-recommended) + - [Using mise and just (from source)](#using-mise-and-just-from-source) + - [Using Docker](#using-docker) + - [Configuration](#configuration) + - [Environment Variables](#environment-variables) + - [Connection Modes](#connection-modes) + - [MCP Client Configuration](#mcp-client-configuration) + - [Usage](#usage) + - [Starting the MCP Bridge in FreeCAD](#starting-the-mcp-bridge-in-freecad) + - [Running Modes](#running-modes) + - [XML-RPC Mode (Recommended)](#xml-rpc-mode-recommended) + - [Socket Mode (JSON-RPC)](#socket-mode-json-rpc) + - [Headless Mode](#headless-mode) + - [Embedded Mode (Linux Only)](#embedded-mode-linux-only) + - [Available Tools](#available-tools) + - [Execution & Debugging (5 tools)](#execution--debugging-5-tools) + - [Document Management (7 tools)](#document-management-7-tools) + - [Object Creation - Primitives (8 tools)](#object-creation---primitives-8-tools) + - [Object Management (12 tools)](#object-management-12-tools) + - [PartDesign - Sketching (14 tools)](#partdesign---sketching-14-tools) + - [PartDesign - Patterns & Edges (5 tools)](#partdesign---patterns--edges-5-tools) + - [View & Display (11 tools)](#view--display-11-tools) + - [Undo/Redo (3 tools)](#undoredo-3-tools) + - [Export/Import (7 tools)](#exportimport-7-tools) + - [Macro Management (6 tools)](#macro-management-6-tools) + - [Parts Library (2 tools)](#parts-library-2-tools) + - [FreeCAD Macros](#freecad-macros) + - [CutObjectForMagnets](#cutobjectformagnets) + - [For Developers](#for-developers) + - [MCP Server Development](#mcp-server-development) + - [Prerequisites](#prerequisites) + - [Initial Setup](#initial-setup) + - [MCP Client Configuration (Development)](#mcp-client-configuration-development) + - [Development Workflow](#development-workflow) + - [Running FreeCAD with the MCP Bridge](#running-freecad-with-the-mcp-bridge) + - [GUI Mode (recommended for development)](#gui-mode-recommended-for-development) + - [Headless Mode (for automation/CI)](#headless-mode-for-automationci) + - [Running Tests](#running-tests) + - [Code Quality](#code-quality) + - [Macro Development](#macro-development) + - [StartMCPBridge Macro](#startmcpbridge-macro) + - [CutObjectForMagnets Macro](#cutobjectformagnets-macro) + - [Architecture](#architecture) + - [License](#license) + + + +## Features + +- **82+ MCP Tools**: Comprehensive CAD operations including primitives, PartDesign, booleans, export +- **Multiple Connection Modes**: XML-RPC (recommended), JSON-RPC socket, or embedded +- **GUI & Headless Support**: Full modeling in headless mode, plus screenshots/colors in GUI mode +- **Macro Development**: Create, edit, run, and template FreeCAD macros via MCP +- **Standalone Macros**: Useful FreeCAD macros that work independently of the MCP server + +## Requirements + +- [FreeCAD](https://www.freecadweb.org/) 0.21+ or 1.0+ +- Python 3.11 (required for FreeCAD ABI compatibility) + +--- + +## For Users + +This section covers installation and usage for end users who want to use the MCP server with AI assistants or the standalone FreeCAD macros. + +## MCP Server + +### Installation + +#### Using pip (recommended) + +```bash +pip install freecad-robust-mcp +``` + +#### Using mise and just (from source) + +```bash +git clone https://github.com/spkane/freecad-mcp.git +cd freecad-mcp +mise trust +mise install +just setup +``` + +#### Using Docker + +Run the MCP server in a container. This is useful for isolated environments or when you don't want to install Python dependencies on your host. + +```bash +# Pull from Docker Hub (when published) +docker pull spkane/freecad-mcp + +# Or build locally +git clone https://github.com/spkane/freecad-mcp.git +cd freecad-mcp +docker build -t freecad-mcp . + +# Or use just commands (if you have mise/just installed) +just docker::build # Build for local architecture +just docker::build-multi # Build multi-arch (amd64 + arm64) +``` + +**Note:** The containerized MCP server only supports `xmlrpc` and `socket` modes since FreeCAD runs on your host machine (not in the container). The container connects to FreeCAD via `host.docker.internal`. + +### Configuration + +#### Environment Variables + +| Variable | Description | Default | +| --------------------- | ---------------------------------------------------- | ----------- | +| `FREECAD_MODE` | Connection mode: `xmlrpc`, `socket`, or `embedded` | `xmlrpc` | +| `FREECAD_PATH` | Path to FreeCAD's lib directory (embedded mode only) | Auto-detect | +| `FREECAD_SOCKET_HOST` | Socket/XML-RPC server hostname | `localhost` | +| `FREECAD_SOCKET_PORT` | JSON-RPC socket server port | `9876` | +| `FREECAD_XMLRPC_PORT` | XML-RPC server port | `9875` | +| `FREECAD_TIMEOUT_MS` | Execution timeout in ms | `30000` | + +#### Connection Modes + +| Mode | Description | Platform Support | +| ---------- | ------------------------------------------- | --------------------------------- | +| `xmlrpc` | Connects to FreeCAD via XML-RPC (port 9875) | **All platforms** (recommended) | +| `socket` | Connects via JSON-RPC socket (port 9876) | **All platforms** | +| `embedded` | Imports FreeCAD directly into process | **Linux only** (crashes on macOS) | + +**Note:** Embedded mode crashes on macOS because FreeCAD's `FreeCAD.so` links to `@rpath/libpython3.11.dylib`, which conflicts with external Python interpreters. Use `xmlrpc` or `socket` mode on macOS and Windows. + +#### MCP Client Configuration + +Add something like the following to your MCP client settings. For Claude Code, this is `~/.claude/claude_desktop_config.json` or a project `.mcp.json` file: + +```json +{ + "mcpServers": { + "freecad": { + "command": "freecad-mcp", + "env": { + "FREECAD_MODE": "xmlrpc" + } + } + } +} +``` + +If installed from source with mise/uv: + +```json +{ + "mcpServers": { + "freecad": { + "command": "/path/to/mise/shims/uv", + "args": ["run", "--project", "/path/to/freecad-mcp", "freecad-mcp"], + "env": { + "FREECAD_MODE": "xmlrpc" + } + } + } +} +``` + +If using Docker: + +```json +{ + "mcpServers": { + "freecad": { + "command": "docker", + "args": [ + "run", "--rm", "-i", + "--add-host=host.docker.internal:host-gateway", + "-e", "FREECAD_MODE=xmlrpc", + "-e", "FREECAD_SOCKET_HOST=host.docker.internal", + "freecad-mcp" + ] + } + } +} +``` + +**Docker configuration notes:** + +- `--rm` removes the container after it exits +- `-i` keeps stdin open for MCP communication +- `--add-host=host.docker.internal:host-gateway` allows the container to connect to FreeCAD on your host (Linux only; macOS/Windows have this built-in) +- `FREECAD_SOCKET_HOST=host.docker.internal` tells the MCP server to connect to FreeCAD on your host machine + +### Usage + +#### Starting the MCP Bridge in FreeCAD + +Before your AI assistant can connect, you need to start the MCP bridge inside FreeCAD: + +1. Install the bridge macro (one time): + + ```bash + # If installed from source: + just install-bridge-macro + + # Or manually copy from the macros/Start_MCP_Bridge/ directory + ``` + +1. Start FreeCAD + +1. Run the macro: **Macro -> Macros -> StartMCPBridge -> Execute** + +1. You should see in the FreeCAD console: + + ```text + MCP Bridge started! + - XML-RPC: localhost:9875 + - Socket: localhost:9876 + ``` + +1. Start/restart your MCP client (Claude Code, etc.) - it will connect automatically + +#### Running Modes + +##### XML-RPC Mode (Recommended) + +Connects to a running FreeCAD instance via XML-RPC. Works on all platforms. + +```bash +FREECAD_MODE=xmlrpc freecad-mcp +``` + +##### Socket Mode (JSON-RPC) + +Connects via JSON-RPC socket. Works on all platforms. + +```bash +FREECAD_MODE=socket freecad-mcp +``` + +##### Headless Mode + +Run FreeCAD in console mode without GUI. Useful for automation. + +```bash +# If installed from source: +just run-headless +``` + +**Note:** Screenshot and view features are not available in headless mode. + +##### Embedded Mode (Linux Only) + +Runs FreeCAD in-process. **Only works on Linux** - crashes on macOS/Windows. + +```bash +FREECAD_MODE=embedded freecad-mcp +``` + +### Available Tools + +The MCP server provides **83 tools** organized into categories. Tools marked with **GUI** require FreeCAD to be running in GUI mode; they will return an error in headless mode. + +#### Execution & Debugging (5 tools) + +| Tool | Description | Mode | +| ---------------------------- | ------------------------------------------------------ | ---- | +| `execute_python` | Execute arbitrary Python code in FreeCAD's context | All | +| `get_freecad_version` | Get FreeCAD version, build date, and Python version | All | +| `get_connection_status` | Check MCP bridge connection status and latency | All | +| `get_console_output` | Get recent FreeCAD console output (up to N lines) | All | +| `get_mcp_server_environment` | Get MCP server environment (OS, hostname, Docker info) | All | + +#### Document Management (7 tools) + +| Tool | Description | Mode | +| --------------------- | ----------------------------------------- | ---- | +| `list_documents` | List all open documents with metadata | All | +| `get_active_document` | Get information about the active document | All | +| `create_document` | Create a new FreeCAD document | All | +| `open_document` | Open an existing .FCStd file | All | +| `save_document` | Save a document to disk | All | +| `close_document` | Close a document (with optional save) | All | +| `recompute_document` | Force recomputation of all objects | All | + +#### Object Creation - Primitives (8 tools) + +| Tool | Description | Mode | +| ----------------- | -------------------------------------------------- | ---- | +| `create_object` | Create a generic FreeCAD object by type ID | All | +| `create_box` | Create a Part::Box with length, width, height | All | +| `create_cylinder` | Create a Part::Cylinder with radius, height, angle | All | +| `create_sphere` | Create a Part::Sphere with radius | All | +| `create_cone` | Create a Part::Cone with two radii and height | All | +| `create_torus` | Create a Part::Torus (donut) with radii and angles | All | +| `create_wedge` | Create a Part::Wedge (tapered box) | All | +| `create_helix` | Create a Part::Helix curve for sweeps and threads | All | + +#### Object Management (12 tools) + +| Tool | Description | Mode | +| ------------------- | -------------------------------------------------- | ---- | +| `list_objects` | List all objects in a document | All | +| `inspect_object` | Get detailed object info (properties, shape, etc.) | All | +| `edit_object` | Modify properties of an existing object | All | +| `delete_object` | Delete an object from a document | All | +| `set_placement` | Set object position and rotation | All | +| `scale_object` | Scale an object uniformly or non-uniformly | All | +| `rotate_object` | Rotate an object around an axis | All | +| `copy_object` | Create a copy of an object | All | +| `mirror_object` | Mirror an object across a plane (XY, XZ, YZ) | All | +| `boolean_operation` | Fuse, cut, or intersect objects | All | +| `get_selection` | Get currently selected objects | GUI | +| `set_selection` | Select specific objects by name | GUI | +| `clear_selection` | Clear all selections | GUI | + +#### PartDesign - Sketching (14 tools) + +| Tool | Description | Mode | +| ------------------------ | ----------------------------------------------- | ---- | +| `create_partdesign_body` | Create a PartDesign::Body container | All | +| `create_sketch` | Create a sketch on a plane or face | All | +| `add_sketch_rectangle` | Add a rectangle to a sketch | All | +| `add_sketch_circle` | Add a circle to a sketch | All | +| `add_sketch_line` | Add a line (with optional construction flag) | All | +| `add_sketch_arc` | Add an arc by center, radius, and angles | All | +| `add_sketch_point` | Add a point (useful for hole centers) | All | +| `pad_sketch` | Extrude a sketch (additive) | All | +| `pocket_sketch` | Cut into solid using a sketch (subtractive) | All | +| `revolution_sketch` | Revolve a sketch around an axis (additive) | All | +| `groove_sketch` | Revolve a sketch around an axis (subtractive) | All | +| `create_hole` | Create parametric holes with optional threading | All | +| `loft_sketches` | Create a loft through multiple sketches | All | +| `sweep_sketch` | Sweep a profile along a spine path | All | + +#### PartDesign - Patterns & Edges (5 tools) + +| Tool | Description | Mode | +| ------------------ | ------------------------------------------ | ---- | +| `linear_pattern` | Create linear pattern of a feature | All | +| `polar_pattern` | Create polar/circular pattern of a feature | All | +| `mirrored_feature` | Mirror a feature across a plane | All | +| `fillet_edges` | Add fillets (rounded edges) | All | +| `chamfer_edges` | Add chamfers (beveled edges) | All | + +#### View & Display (11 tools) + +| Tool | Description | Mode | +| ----------------------- | ----------------------------------------------- | ---- | +| `get_screenshot` | Capture a screenshot of the 3D view | GUI | +| `set_view_angle` | Set camera to standard views (Front, Top, etc.) | GUI | +| `fit_all` | Zoom to fit all objects in view | GUI | +| `zoom_in` | Zoom in by a factor | GUI | +| `zoom_out` | Zoom out by a factor | GUI | +| `set_camera_position` | Set camera position and look-at point | GUI | +| `set_object_visibility` | Show/hide objects | GUI | +| `set_display_mode` | Set display mode (Shaded, Wireframe, etc.) | GUI | +| `set_object_color` | Set object color as RGB values | GUI | +| `list_workbenches` | List available FreeCAD workbenches | All | +| `activate_workbench` | Switch to a different workbench | All | + +#### Undo/Redo (3 tools) + +| Tool | Description | Mode | +| ---------------------- | ---------------------------------- | ---- | +| `undo` | Undo the last operation | All | +| `redo` | Redo a previously undone operation | All | +| `get_undo_redo_status` | Get available undo/redo operations | All | + +#### Export/Import (7 tools) + +| Tool | Description | Mode | +| ------------- | ------------------------------------------ | ---- | +| `export_step` | Export to STEP format (ISO CAD exchange) | All | +| `export_stl` | Export to STL format (3D printing) | All | +| `export_3mf` | Export to 3MF format (modern 3D printing) | All | +| `export_obj` | Export to OBJ format (Wavefront) | All | +| `export_iges` | Export to IGES format (older CAD exchange) | All | +| `import_step` | Import a STEP file | All | +| `import_stl` | Import an STL file as mesh | All | + +#### Macro Management (6 tools) + +| Tool | Description | Mode | +| ---------------------------- | ---------------------------------------------- | ---- | +| `list_macros` | List all available FreeCAD macros | All | +| `run_macro` | Execute a macro by name | All | +| `create_macro` | Create a new macro file | All | +| `read_macro` | Read macro source code | All | +| `delete_macro` | Delete a user macro | All | +| `create_macro_from_template` | Create macro from template (basic, part, etc.) | All | + +#### Parts Library (2 tools) + +| Tool | Description | Mode | +| -------------------------- | ------------------------------------- | ---- | +| `list_parts_library` | List parts in FreeCAD's parts library | All | +| `insert_part_from_library` | Insert a part from the library | All | + +--- + +## FreeCAD Macros + +This project includes standalone FreeCAD macros that can be used independently of the MCP server. These are useful for FreeCAD users who want the macros without setting up the full MCP integration. + +### CutObjectForMagnets + +Intelligently cuts 3D objects along a plane and automatically places magnet holes with built-in surface penetration detection. Perfect for creating multi-part prints that snap together with magnets. + +**Features:** + +- Smart surface detection - skips holes that would penetrate outer surfaces +- Supports preset planes (XY, XZ, YZ) or custom datum planes for angled cuts +- Count-based hole placement with even distribution +- Dual-part validation ensures alignment +- Non-destructive (original object is hidden, not deleted) + +**Installation:** + +```bash +# If you have the source: +just install-cut-macro + +# Or manually copy CutObjectForMagnets.FCMacro from macros/Cut_Object_for_Magnets/ +# to your FreeCAD macro directory: +# macOS: ~/Library/Application Support/FreeCAD/Macro/ +# Linux: ~/.local/share/FreeCAD/Macro/ +# Windows: %APPDATA%/FreeCAD/Macro/ +``` + +**Usage:** + +1. Open your model in FreeCAD +1. Select the object to cut +1. Go to **Macro -> Macros... -> CutObjectForMagnets -> Execute** +1. Configure the cut plane and magnet hole parameters +1. Click **Execute Cut** + +**Parameters:** + +- **Plane:** XY, XZ, YZ, or a model datum plane +- **Offset:** Distance from origin (for preset planes) +- **Hole diameter:** Size of magnet holes (e.g., 6.2mm for 6mm magnets) +- **Hole depth:** How deep holes go into each piece +- **Number of holes:** Total holes to create (evenly distributed) +- **Edge clearance:** Distance from hole edge to outer surface + +See [macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md](macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md) for detailed documentation. + +**Uninstall:** + +```bash +just uninstall-cut-macro +``` + +--- + +## For Developers + +This section covers development setup, contributing, and working with the codebase. + +## MCP Server Development + +### Prerequisites + +- [mise](https://mise.jdx.dev/) - Tool version manager +- [FreeCAD](https://www.freecadweb.org/) 0.21+ or 1.0+ + +### Initial Setup + +```bash +# Clone the repository +git clone https://github.com/spkane/freecad-mcp.git +cd freecad-mcp + +# Install mise (if not already installed) +curl https://mise.run | sh + +# Install all tools (Python 3.11, uv, just, pre-commit) +mise trust +mise install + +# Set up the development environment +just setup +``` + +This installs: + +- **Python 3.11** - Required for FreeCAD ABI compatibility +- **uv** - Fast Python package manager +- **just** - Command runner for development workflows +- **pre-commit** - Git hooks for code quality + +### MCP Client Configuration (Development) + +Create a `.mcp.json` file in the project directory: + +```json +{ + "mcpServers": { + "freecad": { + "command": "/path/to/mise/shims/uv", + "args": ["run", "--project", "/path/to/freecad-mcp", "freecad-mcp"], + "env": { + "FREECAD_MODE": "xmlrpc", + "FREECAD_SOCKET_HOST": "localhost", + "FREECAD_XMLRPC_PORT": "9875", + "PATH": "/path/to/mise/shims:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" + } + } + } +} +``` + +**Replace the paths with your actual paths:** + +| Placeholder | Description | Example (macOS) | +| ------------------------ | ------------------------------ | ------------------------------ | +| `/path/to/mise/shims/uv` | Full path to uv via mise shims | `~/.local/share/mise/shims/uv` | +| `/path/to/freecad-mcp` | Project directory | `~/dev/freecad-mcp` | +| `/path/to/mise/shims` | mise shims directory for PATH | `~/.local/share/mise/shims` | + +**Finding your mise shims path:** + +```bash +mise where uv | sed 's|/installs/.*|/shims|' +# Or simply: +echo ~/.local/share/mise/shims +``` + +### Development Workflow + +Commands are organized into modules. Use `just --list` to see all shortcuts, or `just --list ` to see module-specific commands. + +```bash +# Show all available commands +just --list + +# Show commands in a specific module +just --list docker +just --list quality +just --list testing +just --list freecad + +# Install/update dependencies +just install + +# Run all checks (linting, type checking, tests) +just all + +# Individual checks (shortcuts to module commands) +just lint # Run ruff linter (quality::lint) +just typecheck # Run mypy type checker (quality::typecheck) +just test # Run unit tests (testing::unit) +just check # Run all pre-commit hooks (quality::check) + +# Format code +just format + +# Run with debug logging +just run-debug + +# Docker commands +just docker::build # Build image for local architecture +just docker::build-multi # Build multi-arch image (amd64 + arm64) +just docker::run # Run container +``` + +### Running FreeCAD with the MCP Bridge + +#### GUI Mode (recommended for development) + +```bash +# Install the bridge macro +just install-bridge-macro + +# Start FreeCAD with auto-started bridge +just run-gui + +# Or start FreeCAD manually, then: +# Macro -> Macros -> StartMCPBridge -> Execute +``` + +#### Headless Mode (for automation/CI) + +```bash +just run-headless +``` + +### Running Tests + +```bash +# Unit tests only (no FreeCAD required) +just test + +# Unit tests with coverage +just test-cov + +# Integration tests (requires running FreeCAD bridge) +just test-integration + +# All tests with automatic FreeCAD startup +just integration +``` + +### Code Quality + +The project uses strict code quality checks via pre-commit: + +- **Ruff** - Linting and formatting +- **MyPy** - Type checking +- **Bandit** - Security scanning +- **Codespell** - Spell checking +- **Secrets scanning** - Gitleaks, detect-secrets, TruffleHog + +```bash +# Run all pre-commit hooks +just check + +# Run security/secrets scans +just security +just secrets +``` + +--- + +## Macro Development + +### StartMCPBridge Macro + +The StartMCPBridge macro is required for the MCP server to communicate with FreeCAD. Developers working on the MCP integration should understand how it works. + +**Location:** `macros/Start_MCP_Bridge/` + +**What it does:** + +1. Adds the freecad-mcp project source to Python's path +1. Imports and instantiates the `FreecadMCPPlugin` +1. Starts both XML-RPC (port 9875) and JSON-RPC (port 9876) servers +1. Registers handlers for executing Python code, managing documents, creating objects, etc. + +**Installation for development:** + +```bash +just install-bridge-macro +``` + +This copies the macro to FreeCAD's macro directory and injects the correct project path. + +**Uninstall:** + +```bash +just uninstall-bridge-macro +``` + +### CutObjectForMagnets Macro + +**Location:** `macros/Cut_Object_for_Magnets/` + +**Installation for development:** + +```bash +just install-cut-macro +``` + +**Uninstall:** + +```bash +just uninstall-cut-macro +``` + +See [macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md](macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md) for detailed documentation on the macro's internals. + +--- + +## Architecture + +See [ARCHITECTURE-MCP.md](ARCHITECTURE-MCP.md) for detailed design documentation covering: + +- Module structure +- Bridge communication protocols +- Tool registration patterns +- FreeCAD plugin architecture + +--- + +## License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md new file mode 100644 index 0000000..b16fb39 --- /dev/null +++ b/docs/COMPARISON.md @@ -0,0 +1,307 @@ +# FreeCAD MCP Server Comparison Analysis + +This document analyzes existing FreeCAD MCP server implementations to identify best practices and improvements for our architecture. + +## Existing Implementations + +### 1. [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp) (380+ stars) + +**Architecture:** + +- XML-RPC server running inside FreeCAD (port 9875) +- Queue-based GUI communication for thread safety +- FastMCP-based MCP server connecting via XML-RPC + +**Key Features:** + +- `create_object()` supports Part::, Draft::, PartDesign::, Fem:: types +- `get_view()` captures screenshots with multiple perspectives +- `insert_part_from_library()` for part library access +- Smart screenshot handling (detects unsupported views like TechDraw) + +**Strengths:** + +- Thread-safe queue system for GUI operations +- Comprehensive object type support +- Screenshot capabilities with view selection +- Parts library integration + +**Weaknesses:** + +- XML-RPC only (no embedded/headless mode) +- No macro development tools +- Limited debugging capabilities + +--- + +### 2. [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad) + +**Architecture:** + +- 6 connection modes: Launcher, Server, Bridge, RPC, Wrapper, Mock +- FastMCP 2.13.0+ with FastAPI integration +- Multi-provider AI support (Claude, OpenAI, Google, OpenRouter) + +**Key Features:** + +- AppImage/AppRun launcher integration +- Connection recovery mechanisms +- Resource caching and performance diagnostics +- Modern GUI addon with real-time diagnostics + +**Strengths:** + +- Most flexible connection options +- Multi-AI provider support +- Performance monitoring +- Connection recovery/resilience + +**Weaknesses:** + +- Complex setup with many moving parts +- Heavier dependency footprint + +--- + +### 3. [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp) + +**Architecture:** + +- Node.js-based MCP bridge (`working_bridge.py`) +- AICopilot workbench integrated into FreeCAD +- Cross-platform installer (`freecad-mcp-setup`) + +**Key Features:** + +- 13 PartDesign operations (Pad, Revolution, Fillet, Chamfer, etc.) +- 18 Part operations (Primitives, Booleans, Transforms) +- 14 View control tools +- Automated installer with OS detection + +**Strengths:** + +- Most comprehensive CAD operation coverage +- Excellent PartDesign/Part workbench integration +- Easy installation process +- Demo showcasing full workflow (house modeling) + +**Weaknesses:** + +- Node.js dependency adds complexity +- Less focus on debugging/development workflows + +--- + +### 4. [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP) + +**Architecture:** + +- Server-client with stdio/TCP (port 9876) +- GUI control panel inside FreeCAD +- Macro-centric workflow + +**Key Features:** + +- Macro templates (default, basic, part, sketch) +- Automatic import injection in macros +- Macro validation before execution +- View control (front, top, right, axonometric) +- Log management and report browser + +**Strengths:** + +- Focus on macro development workflow +- GUI panel for easy control +- Automatic boilerplate injection +- Comprehensive logging + +**Weaknesses:** + +- Macro-only focus (no direct object creation) +- Windows-centric paths in documentation + +--- + +### 5. [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp) + +**Architecture:** + +- Simple socket-based server +- Two primary tools: `get_scene_info` and `run_script` + +**Key Features:** + +- Comprehensive scene information retrieval +- Arbitrary Python code execution +- Minimal, focused API + +**Strengths:** + +- Simple, easy to understand +- Full Python access via `run_script` +- Lightweight + +**Weaknesses:** + +- Very minimal tool set +- No specialized CAD operations + +--- + +## Feature Comparison Matrix + +| Feature | neka-nat | jango | contextform | ATOI-Ming | bonninr | **Ours** | +| ------------------- | -------- | ----- | ----------- | --------- | ------- | -------- | +| Headless mode | No | Yes | No | No | No | **Yes** | +| GUI mode | Yes | Yes | Yes | Yes | Yes | **Yes** | +| XML-RPC | Yes | Yes | No | No | No | **Yes** | +| Socket/TCP | No | Yes | No | Yes | Yes | **Yes** | +| Embedded Python | No | No | No | No | No | **Yes** | +| Screenshot capture | Yes | Yes | Yes | No | No | **Yes** | +| Parts library | Yes | No | No | No | No | **Yes** | +| Macro support | No | No | No | Yes | No | **Yes** | +| PartDesign tools | Basic | Yes | Yes | Via macro | No | **Yes** | +| Boolean operations | Yes | Yes | Yes | Via macro | No | **Yes** | +| FEM support | Yes | No | No | No | No | **Yes** | +| Multi-AI provider | No | Yes | No | No | No | No | +| Connection recovery | No | Yes | No | No | No | **Yes** | +| Thread-safe GUI ops | Yes | Yes | Unknown | Unknown | Unknown | **Yes** | +| MCP Resources | No | No | No | No | No | **Yes** | +| MCP Prompts | No | No | No | No | No | **Yes** | + +--- + +## Key Learnings & Improvements for Our Architecture + +### 1. Communication Protocol + +**Learning:** neka-nat uses XML-RPC which is proven and reliable. + +**Improvement:** Support both XML-RPC (for compatibility) and JSON-RPC over sockets (simpler, more modern). Add embedded mode for true headless operation. + +### 2. Thread-Safe GUI Operations + +**Learning:** neka-nat's queue-based system is essential for GUI stability. + +**Improvement:** Implement similar queue system with Qt timer for main thread execution: + +```python +# Queue-based GUI communication +rpc_request_queue = queue.Queue() +rpc_response_queue = queue.Queue() + +def process_gui_tasks(): + """Execute queued operations on main GUI thread.""" + while not rpc_request_queue.empty(): + task = rpc_request_queue.get() + result = task() + rpc_response_queue.put(result) +``` + +### 3. Screenshot Capabilities + +**Learning:** neka-nat handles unsupported view types gracefully. + +**Improvement:** Add comprehensive view support with intelligent fallbacks: + +- Multiple view angles (Isometric, Front, Top, Right, etc.) +- View type detection (skip TechDraw, Spreadsheet) +- Configurable resolution and format + +### 4. Macro Development Focus + +**Learning:** ATOI-Ming's macro-centric approach is unique and valuable. + +**Improvement:** Add dedicated macro tools: + +- `create_macro` with templates +- `validate_macro` for syntax checking +- `run_macro` with parameter passing +- Automatic import injection + +### 5. PartDesign Integration + +**Learning:** contextform has the most comprehensive PartDesign coverage. + +**Improvement:** Add specialized PartDesign tools: + +- Pad, Pocket, Revolution, Groove +- Fillet, Chamfer +- Hole (with standards support) +- LinearPattern, PolarPattern +- Mirrored + +### 6. Connection Resilience + +**Learning:** jango-blockchained has connection recovery mechanisms. + +**Improvement:** Add connection health monitoring and auto-reconnect: + +```python +async def maintain_connection(self): + """Background task to maintain connection health.""" + while self._running: + if not await self.is_connected(): + await self.reconnect() + await asyncio.sleep(5) +``` + +### 7. Parts Library Integration + +**Learning:** neka-nat provides access to FreeCAD parts library. + +**Improvement:** Expose parts library with search and filtering. + +### 8. Comprehensive Logging + +**Learning:** ATOI-Ming has excellent logging with GUI browser. + +**Improvement:** Add structured logging with levels, rotation, and optional GUI viewer in plugin. + +--- + +## Updated Architecture Decisions + +Based on this analysis, our architecture will: + +1. **Support 3 connection modes:** + + - Embedded (headless, in-process FreeCAD) + - XML-RPC (proven, compatible with neka-nat addon) + - JSON-RPC over socket (modern, simpler) + +1. **Implement queue-based GUI thread safety** following neka-nat's pattern + +1. **Provide comprehensive tool coverage:** + + - Execution: Python, macros + - Documents: create, open, save, list + - Objects: create, edit, delete, query + - PartDesign: pad, pocket, fillet, chamfer, patterns + - Part: primitives, booleans, transforms + - Export: STEP, STL, OBJ, FreeCAD native + - View: screenshot, camera control + +1. **Add unique features:** + + - MCP Resources for document introspection + - MCP Prompts for guided workflows + - Macro development toolkit + - Workbench-specific tools + +1. **Focus on developer experience:** + + - Debugging tools + - Error introspection + - Console history access + - Constraint validation + +--- + +## Sources + +- [neka-nat/freecad-mcp](https://github.com/neka-nat/freecad-mcp) +- [jango-blockchained/mcp-freecad](https://github.com/jango-blockchained/mcp-freecad) +- [contextform/freecad-mcp](https://github.com/contextform/freecad-mcp) +- [ATOI-Ming/FreeCAD-MCP](https://github.com/ATOI-Ming/FreeCAD-MCP) +- [bonninr/freecad_mcp](https://github.com/bonninr/freecad_mcp) diff --git a/docs/MCP_TOOLS_REFERENCE.md b/docs/MCP_TOOLS_REFERENCE.md new file mode 100644 index 0000000..af353a3 --- /dev/null +++ b/docs/MCP_TOOLS_REFERENCE.md @@ -0,0 +1,1157 @@ +# FreeCAD MCP Tools Reference + +This document provides a comprehensive reference for all MCP tools available in the FreeCAD MCP server. + +--- + +## Overview + +The FreeCAD MCP server exposes tools organized into the following categories: + +| Category | Description | Tool Count | +| ------------------------------- | -------------------------------- | ---------- | +| [Execution](#execution-tools) | Python execution and system info | 5 | +| [Documents](#document-tools) | Document management | 7 | +| [Objects](#object-tools) | Object creation and manipulation | 22 | +| [PartDesign](#partdesign-tools) | Parametric solid modeling | 20 | +| [Export](#export-tools) | Import/export operations | 7 | +| [View](#view-tools) | View control and screenshots | 18 | +| [Macros](#macro-tools) | Macro management | 6 | +| **Total** | | **85** | + +--- + +## Execution Tools + +Tools for executing Python code and getting system information. + +### execute_python + +Execute arbitrary Python code in FreeCAD's context. + +```python +execute_python( + code: str, + timeout_ms: int = 30000 +) -> dict +``` + +**Parameters:** + +- `code`: Python code to execute. Use `_result_ = value` to return data. +- `timeout_ms`: Maximum execution time in milliseconds. + +**Returns:** Execution result with stdout, stderr, and any assigned `_result_`. + +**Example:** + +```python +await execute_python(''' +import Part +box = Part.makeBox(10, 20, 30) +_result_ = {"volume": box.Volume} +''') +``` + +### get_freecad_version + +Get FreeCAD version and build information. + +```python +get_freecad_version() -> dict +``` + +**Returns:** Version string, build date, Python version, GUI availability. + +### get_connection_status + +Get the current bridge connection status. + +```python +get_connection_status() -> dict +``` + +**Returns:** Connection state, mode (xmlrpc/socket/embedded), latency. + +### get_console_output + +Get recent FreeCAD console output. + +```python +get_console_output(lines: int = 100) -> list[str] +``` + +### get_mcp_server_environment + +Get environment information about the MCP server process. Useful for identifying whether the MCP server is running in a Docker container or on the host system. + +```python +get_mcp_server_environment() -> dict +``` + +**Returns:** Dictionary containing: + +- `hostname`: Server hostname +- `os_name`: Operating system name (e.g., "Linux", "Darwin", "Windows") +- `os_version`: OS version/release +- `platform`: Full platform string +- `python_version`: Python version +- `in_docker`: Boolean indicating if running in Docker +- `docker_container_id`: Container ID (first 12 chars) if in Docker +- `env_vars`: Relevant environment variables (FREECAD_MODE, etc.) + +--- + +## Document Tools + +Tools for managing FreeCAD documents. + +### list_documents + +List all open documents. + +```python +list_documents() -> list[dict] +``` + +### get_active_document + +Get the currently active document. + +```python +get_active_document() -> dict | None +``` + +### create_document + +Create a new document. + +```python +create_document( + name: str = "Unnamed", + label: str | None = None +) -> dict +``` + +### open_document + +Open an existing FreeCAD file. + +```python +open_document(path: str) -> dict +``` + +### save_document + +Save a document. + +```python +save_document( + doc_name: str | None = None, + path: str | None = None +) -> dict +``` + +### close_document + +Close a document. + +```python +close_document( + doc_name: str | None = None, + save_changes: bool = False +) -> dict +``` + +### recompute_document + +Recompute all objects in a document. + +```python +recompute_document(doc_name: str | None = None) -> dict +``` + +--- + +## Object Tools + +Tools for creating and manipulating FreeCAD objects. + +### Primitive Creation + +#### create_box + +Create a parametric box. + +```python +create_box( + length: float = 10.0, + width: float = 10.0, + height: float = 10.0, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_cylinder + +Create a parametric cylinder. + +```python +create_cylinder( + radius: float = 5.0, + height: float = 10.0, + angle: float = 360.0, # For partial cylinders + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_sphere + +Create a parametric sphere. + +```python +create_sphere( + radius: float = 5.0, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_cone + +Create a parametric cone. + +```python +create_cone( + radius1: float = 5.0, # Bottom radius + radius2: float = 0.0, # Top radius (0 = pointed) + height: float = 10.0, + angle: float = 360.0, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_torus + +Create a torus (donut shape). + +```python +create_torus( + radius1: float = 10.0, # Major radius + radius2: float = 2.0, # Minor radius + angle1: float = -180.0, + angle2: float = 180.0, + angle3: float = 360.0, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_wedge + +Create a tapered wedge shape. + +```python +create_wedge( + xmin: float = 0.0, ymin: float = 0.0, zmin: float = 0.0, + x2min: float = 2.0, z2min: float = 2.0, + xmax: float = 10.0, ymax: float = 10.0, zmax: float = 10.0, + x2max: float = 8.0, z2max: float = 8.0, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_helix + +Create a helix curve (for threads, springs). + +```python +create_helix( + pitch: float = 5.0, + height: float = 20.0, + radius: float = 5.0, + angle: float = 0.0, + left_handed: bool = False, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Object Management + +#### list_objects + +List all objects in a document. + +```python +list_objects(doc_name: str | None = None) -> list[dict] +``` + +#### inspect_object + +Get detailed information about an object. + +```python +inspect_object( + object_name: str, + doc_name: str | None = None, + include_properties: bool = True, + include_shape: bool = True +) -> dict +``` + +#### create_object + +Create a generic FreeCAD object by type ID. + +```python +create_object( + type_id: str, # e.g., "Part::Box", "Sketcher::SketchObject" + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None +) -> dict +``` + +#### edit_object + +Modify object properties. + +```python +edit_object( + object_name: str, + properties: dict[str, Any], + doc_name: str | None = None +) -> dict +``` + +#### delete_object + +Delete an object. + +```python +delete_object( + object_name: str, + doc_name: str | None = None +) -> dict +``` + +### Boolean Operations + +#### boolean_operation + +Perform boolean operations (union, subtract, intersect). + +```python +boolean_operation( + operation: str, # "fuse", "cut", or "common" + object1_name: str, + object2_name: str, + result_name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +**Operations:** + +- `fuse` - Union/combine shapes +- `cut` - Subtract object2 from object1 +- `common` - Intersection of shapes + +### Transformations + +#### set_placement + +Set object position and rotation. + +```python +set_placement( + object_name: str, + position: list[float] | None = None, # [x, y, z] + rotation: list[float] | None = None, # [yaw, pitch, roll] in degrees + doc_name: str | None = None +) -> dict +``` + +#### rotate_object + +Rotate object around an axis. + +```python +rotate_object( + object_name: str, + axis: list[float], # [x, y, z] rotation axis + angle: float, # Degrees + center: list[float] | None = None, + doc_name: str | None = None +) -> dict +``` + +#### scale_object + +Scale an object (creates new copy). + +```python +scale_object( + object_name: str, + scale: float | list[float], # Uniform or [sx, sy, sz] + result_name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### copy_object + +Create a copy of an object. + +```python +copy_object( + object_name: str, + new_name: str | None = None, + offset: list[float] | None = None, # [x, y, z] + doc_name: str | None = None +) -> dict +``` + +#### mirror_object + +Mirror object across a plane. + +```python +mirror_object( + object_name: str, + plane: str = "XY", # "XY", "XZ", or "YZ" + result_name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Selection (GUI Mode) + +#### get_selection + +Get currently selected objects. + +```python +get_selection(doc_name: str | None = None) -> list[dict] +``` + +#### set_selection + +Select objects programmatically. + +```python +set_selection( + object_names: list[str], + clear_existing: bool = True, + doc_name: str | None = None +) -> dict +``` + +#### clear_selection + +Clear the current selection. + +```python +clear_selection() -> dict +``` + +--- + +## PartDesign Tools + +Tools for parametric solid modeling using the PartDesign workbench. + +### Bodies and Sketches + +#### create_partdesign_body + +Create a PartDesign Body container. + +```python +create_partdesign_body( + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_sketch + +Create a sketch attached to a plane or body. + +```python +create_sketch( + body_name: str | None = None, + plane: str = "XY_Plane", # "XY_Plane", "XZ_Plane", "YZ_Plane", or "FaceN" + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Sketch Geometry + +#### add_sketch_rectangle + +Add a rectangle to a sketch. + +```python +add_sketch_rectangle( + sketch_name: str, + x: float, y: float, # Bottom-left corner + width: float, height: float, + doc_name: str | None = None +) -> dict +``` + +#### add_sketch_circle + +Add a circle to a sketch. + +```python +add_sketch_circle( + sketch_name: str, + center_x: float, center_y: float, + radius: float, + doc_name: str | None = None +) -> dict +``` + +#### add_sketch_line + +Add a line to a sketch. + +```python +add_sketch_line( + sketch_name: str, + x1: float, y1: float, # Start point + x2: float, y2: float, # End point + construction: bool = False, + doc_name: str | None = None +) -> dict +``` + +#### add_sketch_arc + +Add an arc to a sketch. + +```python +add_sketch_arc( + sketch_name: str, + center_x: float, center_y: float, + radius: float, + start_angle: float, # Degrees + end_angle: float, # Degrees + doc_name: str | None = None +) -> dict +``` + +#### add_sketch_point + +Add a point to a sketch (useful for hole centers). + +```python +add_sketch_point( + sketch_name: str, + x: float, y: float, + doc_name: str | None = None +) -> dict +``` + +### Additive Features + +#### pad_sketch + +Extrude a sketch to create material. + +```python +pad_sketch( + sketch_name: str, + length: float, + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### revolution_sketch + +Revolve a sketch around an axis. + +```python +revolution_sketch( + sketch_name: str, + angle: float = 360.0, + axis: str = "Base_X", # "Base_X/Y/Z" or "Sketch_V/H" + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### loft_sketches + +Loft through multiple sketches. + +```python +loft_sketches( + sketch_names: list[str], + ruled: bool = False, + closed: bool = False, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### sweep_sketch + +Sweep a profile along a path. + +```python +sweep_sketch( + profile_sketch: str, + spine_sketch: str, + transition: str = "Transformed", # "Transformed", "Right", "Round" + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Subtractive Features + +#### pocket_sketch + +Cut material by extruding a sketch. + +```python +pocket_sketch( + sketch_name: str, + length: float, + type: str = "Length", # "Length", "ThroughAll", "UpToFirst", "UpToFace" + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### groove_sketch + +Cut material by revolving a sketch. + +```python +groove_sketch( + sketch_name: str, + angle: float = 360.0, + axis: str = "Base_X", + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### create_hole + +Create parametric holes with optional threading. + +```python +create_hole( + sketch_name: str, # Sketch with center point(s) + diameter: float = 6.0, + depth: float = 10.0, + hole_type: str = "Dimension", # "Dimension", "ThroughAll", "UpToFirst" + threaded: bool = False, + thread_type: str = "ISO", # "ISO", "UNC", "UNF" + thread_size: str = "M6", + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Edge Operations + +#### fillet_edges + +Add rounded edges. + +```python +fillet_edges( + object_name: str, + radius: float, + edges: list[str] | None = None, # ["Edge1", "Edge2"] or None for all + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### chamfer_edges + +Add beveled edges. + +```python +chamfer_edges( + object_name: str, + size: float, + edges: list[str] | None = None, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +### Patterns + +#### linear_pattern + +Repeat a feature in a linear direction. + +```python +linear_pattern( + feature_name: str, + direction: str = "X", # "X", "Y", "Z" + length: float = 50.0, + occurrences: int = 3, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### polar_pattern + +Repeat a feature around an axis. + +```python +polar_pattern( + feature_name: str, + axis: str = "Z", # "X", "Y", "Z" + angle: float = 360.0, + occurrences: int = 6, + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +#### mirrored_feature + +Mirror a feature across a plane. + +```python +mirrored_feature( + feature_name: str, + plane: str = "XY", # "XY", "XZ", "YZ" + name: str | None = None, + doc_name: str | None = None +) -> dict +``` + +--- + +## Export Tools + +Tools for importing and exporting CAD files. + +### export_step + +Export objects to STEP format. + +```python +export_step( + file_path: str, + object_names: list[str] | None = None, # None = all visible + doc_name: str | None = None +) -> dict +``` + +### export_stl + +Export objects to STL format (for 3D printing). + +```python +export_stl( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + mesh_tolerance: float = 0.1 +) -> dict +``` + +### export_3mf + +Export objects to 3MF format (modern 3D printing format). + +3MF (3D Manufacturing Format) supports richer data than STL, including colors, materials, and print settings. It is increasingly preferred over STL for 3D printing. + +```python +export_3mf( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + mesh_tolerance: float = 0.1 +) -> dict +``` + +### export_obj + +Export objects to OBJ format. + +```python +export_obj( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None +) -> dict +``` + +### export_iges + +Export objects to IGES format. + +```python +export_iges( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None +) -> dict +``` + +### import_step + +Import a STEP file. + +```python +import_step( + file_path: str, + doc_name: str | None = None +) -> dict +``` + +### import_stl + +Import an STL file. + +```python +import_stl( + file_path: str, + doc_name: str | None = None +) -> dict +``` + +--- + +## View Tools + +Tools for controlling the 3D view and capturing screenshots. + +### Screenshots + +#### get_screenshot + +Capture a screenshot of the 3D view. + +**Requires GUI mode.** + +```python +get_screenshot( + view_angle: str = "Isometric", + width: int = 800, + height: int = 600, + doc_name: str | None = None +) -> dict # Returns base64-encoded PNG +``` + +**View angles:** `Isometric`, `Front`, `Back`, `Top`, `Bottom`, `Left`, `Right`, `FitAll` + +### View Control + +#### set_view_angle + +Set the 3D view angle. + +```python +set_view_angle( + view_angle: str, # Same options as get_screenshot + doc_name: str | None = None +) -> dict +``` + +#### fit_all + +Fit all objects in the view. + +```python +fit_all(doc_name: str | None = None) -> dict +``` + +#### zoom_in / zoom_out + +Zoom the view. + +**Requires GUI mode.** + +```python +zoom_in(factor: float = 1.5, doc_name: str | None = None) -> dict +zoom_out(factor: float = 1.5, doc_name: str | None = None) -> dict +``` + +#### set_camera_position + +Set custom camera position. + +**Requires GUI mode.** + +```python +set_camera_position( + position: list[float], # [x, y, z] + look_at: list[float] | None = None, # Default: origin + doc_name: str | None = None +) -> dict +``` + +### Object Appearance + +#### set_object_visibility + +Show or hide an object. + +**Requires GUI mode.** + +```python +set_object_visibility( + object_name: str, + visible: bool, + doc_name: str | None = None +) -> dict +``` + +#### set_display_mode + +Set object display mode. + +**Requires GUI mode.** + +```python +set_display_mode( + object_name: str, + mode: str, # "Flat Lines", "Shaded", "Wireframe", "Points" + doc_name: str | None = None +) -> dict +``` + +#### set_object_color + +Set object color. + +**Requires GUI mode.** + +```python +set_object_color( + object_name: str, + color: list[float], # [r, g, b] where values are 0.0-1.0 + doc_name: str | None = None +) -> dict +``` + +### Workbenches + +#### list_workbenches + +List available workbenches. + +```python +list_workbenches() -> list[dict] +``` + +#### activate_workbench + +Activate a workbench. + +```python +activate_workbench(workbench_name: str) -> dict +``` + +**Common workbenches:** + +- `PartWorkbench` - Part modeling +- `PartDesignWorkbench` - Parametric design +- `SketcherWorkbench` - 2D sketching +- `DraftWorkbench` - 2D drafting +- `MeshWorkbench` - Mesh operations + +### Undo/Redo + +#### undo / redo + +Undo or redo operations. + +```python +undo(doc_name: str | None = None) -> dict +redo(doc_name: str | None = None) -> dict +``` + +#### get_undo_redo_status + +Get undo/redo availability. + +```python +get_undo_redo_status(doc_name: str | None = None) -> dict +``` + +### Parts Library + +#### list_parts_library + +List available library parts. + +```python +list_parts_library() -> list[dict] +``` + +#### insert_part_from_library + +Insert a part from the library. + +```python +insert_part_from_library( + part_path: str, + name: str | None = None, + position: list[float] | None = None, + doc_name: str | None = None +) -> dict +``` + +### Utility + +#### get_console_log + +Get FreeCAD console output. + +```python +get_console_log(lines: int = 50) -> dict +``` + +#### recompute + +Force recompute of all objects. + +```python +recompute(doc_name: str | None = None) -> dict +``` + +--- + +## Macro Tools + +Tools for managing FreeCAD macros. + +### list_macros + +List available macros. + +```python +list_macros() -> list[dict] +``` + +### run_macro + +Execute a macro by name. + +```python +run_macro( + macro_name: str, + args: dict[str, Any] | None = None +) -> dict +``` + +### create_macro + +Create a new macro. + +```python +create_macro( + name: str, + code: str, + description: str = "" +) -> dict +``` + +### read_macro + +Read macro contents. + +```python +read_macro(macro_name: str) -> dict +``` + +### delete_macro + +Delete a user macro. + +```python +delete_macro(macro_name: str) -> dict +``` + +### create_macro_from_template + +Create macro from a predefined template. + +```python +create_macro_from_template( + name: str, + template: str = "basic", # "basic", "part", "sketch", "gui", "selection" + description: str = "" +) -> dict +``` + +--- + +## GUI vs Headless Mode + +Some tools require FreeCAD to be running in GUI mode. When running in headless mode, these tools will return an error instead of crashing. + +**GUI-only tools:** + +- `get_screenshot` +- `set_object_visibility` +- `set_display_mode` +- `set_object_color` +- `zoom_in` / `zoom_out` +- `set_camera_position` +- `get_selection` / `set_selection` / `clear_selection` + +**To check mode programmatically:** + +```python +result = await execute_python("_result_ = FreeCAD.GuiUp") +is_gui_mode = result["result"] +``` + +--- + +## Error Handling + +All tools return dictionaries with consistent error handling: + +**Success:** + +```python +{ + "success": True, + "name": "Box", + "volume": 6000.0, + # ... other fields +} +``` + +**Failure:** + +```python +{ + "success": False, + "error": "Object not found: MissingBox" +} +``` + +For tools that raise exceptions, wrap calls in try/except or check the returned error field. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..4735bdd --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,485 @@ +# FreeCAD MCP User Guide + +This guide explains how to use AI assistants with FreeCAD via the MCP (Model Context Protocol) server to create and manipulate 3D CAD models. + +--- + +## Table of Contents + +1. [Getting Started](#getting-started) +1. [Running Modes](#running-modes) +1. [Basic Workflows](#basic-workflows) +1. [Object Creation Examples](#object-creation-examples) +1. [PartDesign Workflow](#partdesign-workflow) +1. [Complete Example: Mounting Bracket](#complete-example-mounting-bracket) +1. [Tips and Best Practices](#tips-and-best-practices) + +--- + +## Getting Started + +### Prerequisites + +1. **FreeCAD installed** - Version 1.0.x or later +1. **An MCP client** (Claude Code, or other MCP-compatible AI assistant) configured +1. **Python 3.11** - Must match FreeCAD's bundled Python version + +### Starting FreeCAD with MCP Bridge + +You have two options depending on your workflow: + +#### Headless Mode (Command-line only) + +Best for automated workflows, batch processing, or when you don't need visual feedback. + +```bash +just run-headless +``` + +**Capabilities:** All modeling operations, export, scripting. +**Limitations:** No screenshots, no visual feedback, no color/visibility control. + +#### GUI Mode (Full graphical interface) + +Best for interactive design work where you want to see results visually. + +```bash +just run-gui +``` + +**Capabilities:** Everything headless mode can do, plus screenshots, colors, view control. + +### Verifying Connection + +Once FreeCAD is running with the MCP bridge, you can verify the connection: + +```text +"Check the FreeCAD connection status" +``` + +Claude will use the `get_connection_status` tool to confirm the bridge is working. + +--- + +## Running Modes + +### Headless vs GUI Mode + +| Feature | Headless Mode | GUI Mode | +| ------------------------ | ------------- | -------- | +| Object creation | Yes | Yes | +| Boolean operations | Yes | Yes | +| Export (STEP, STL, etc.) | Yes | Yes | +| Save documents | Yes | Yes | +| Screenshots | No | Yes | +| Object colors | No | Yes | +| Object visibility | No | Yes | +| Camera control | No | Yes | +| Interactive selection | No | Yes | + +### Detecting the Current Mode + +When working with Claude, it will automatically detect whether FreeCAD is in GUI or headless mode and adapt accordingly. GUI-only operations will return informative errors in headless mode rather than crashing. + +--- + +## Basic Workflows + +### Creating a Document + +Every FreeCAD project needs a document. You can ask Claude: + +```text +"Create a new FreeCAD document called 'MyProject'" +``` + +Claude will use the `create_document` tool. + +### Creating Simple Shapes + +Ask Claude to create primitive shapes: + +```text +"Create a box that's 50mm long, 30mm wide, and 10mm tall" +"Add a cylinder with radius 5mm and height 20mm" +"Create a sphere with 15mm radius" +``` + +### Positioning Objects + +Move and rotate objects: + +```text +"Move the box to position (100, 50, 0)" +"Rotate the cylinder 45 degrees around the Z axis" +``` + +### Boolean Operations + +Combine shapes: + +```text +"Fuse the box and cylinder together" +"Cut a hole through the box using the cylinder" +"Find the intersection of the two shapes" +``` + +### Saving and Exporting + +```text +"Save the document as MyProject.FCStd" +"Export the model to STEP format" +"Export for 3D printing as STL" +``` + +--- + +## Object Creation Examples + +### Example 1: Simple Box with Hole + +**Request:** + +```text +Create a 50x50x20mm box with a 10mm diameter hole through the center. +``` + +**What Claude does:** + +1. Creates a document +1. Creates a box (50x50x20) +1. Creates a cylinder (radius 5, height 30) positioned at center +1. Performs boolean cut operation +1. Returns the result + +### Example 2: Pipe/Tube Shape + +**Request:** + +```text +Create a pipe with outer diameter 40mm, inner diameter 30mm, and length 100mm. +``` + +**What Claude does:** + +1. Creates outer cylinder (radius 20, height 100) +1. Creates inner cylinder (radius 15, height 100) +1. Cuts inner from outer to create hollow tube + +### Example 3: L-Bracket + +**Request:** + +```text +Create an L-shaped bracket: +- Horizontal part: 100mm x 50mm x 5mm +- Vertical part: 5mm x 50mm x 80mm standing on the horizontal part +``` + +**What Claude does:** + +1. Creates horizontal box +1. Creates vertical box positioned at correct location +1. Fuses them together + +--- + +## PartDesign Workflow + +For parametric modeling that maintains design history, use the PartDesign workflow. + +### Understanding PartDesign Concepts + +**Body**: A container for a single solid model built from features. + +**Sketch**: A 2D drawing that defines profiles for 3D operations. + +**Features**: Operations like Pad (extrude), Pocket (cut), Fillet, etc. + +### Basic PartDesign Example + +**Request:** + +```text +Create a parametric mounting plate: +1. Start with a PartDesign body +2. Create a sketch on the XY plane with a 100x60mm rectangle +3. Pad it 8mm thick +4. Add four 5mm holes near each corner for mounting screws +5. Fillet all edges with 2mm radius +``` + +**What Claude does:** + +1. `create_partdesign_body()` - Creates the Body container +1. `create_sketch(body_name="Body", plane="XY_Plane")` - Creates attached sketch +1. `add_sketch_rectangle(...)` - Adds the profile geometry +1. `pad_sketch(sketch_name="Sketch", length=8)` - Extrudes to solid +1. Creates new sketches with points for hole locations +1. `create_hole(...)` for each mounting hole +1. `fillet_edges(...)` - Rounds the edges + +### Revolving Profiles + +**Request:** + +```text +Create a turned part: +- Draw a profile on the XZ plane +- Revolve it 360 degrees around the X axis +``` + +**What Claude does:** + +1. Creates body and sketch on XZ plane +1. Draws the profile using lines and arcs +1. Uses `revolution_sketch()` to create the solid + +### Pattern Operations + +**Request:** + +```text +Create a plate with a row of 6 holes spaced 15mm apart. +``` + +**What Claude does:** + +1. Creates the base plate +1. Creates one hole feature +1. Uses `linear_pattern()` to repeat the hole + +--- + +## Complete Example: Mounting Bracket + +This detailed example shows a complete workflow for creating a practical part. + +### Design Requirements + +Create a mounting bracket with: + +- Base plate: 80mm x 60mm x 5mm +- Vertical support: 5mm thick, 50mm tall, 60mm wide +- Two mounting holes (6mm diameter) on the base +- One slot (10mm x 20mm) on the vertical support +- 3mm fillets on external corners + +### Step-by-Step Workflow + +#### Step 1: Ask Claude to create the bracket + +```text +Create a mounting bracket with the following specifications: + +Base plate: +- Size: 80mm x 60mm x 5mm +- Two 6mm diameter mounting holes, centered 15mm from each short edge + +Vertical support: +- Attached to one end of the base +- 5mm thick, 60mm wide, 50mm tall +- One slot: 10mm wide x 20mm tall, centered + +Finish: +- 3mm fillet on all outer edges +- Export as STEP file when done +``` + +#### Step 2: Claude's approach + +Claude will break this down into manageable operations: + +```python +# 1. Create document and PartDesign body +create_document(name="MountingBracket") +create_partdesign_body(name="Body") + +# 2. Create base plate sketch and pad +create_sketch(body_name="Body", plane="XY_Plane", name="BaseSketch") +add_sketch_rectangle(sketch_name="BaseSketch", x=0, y=0, width=80, height=60) +pad_sketch(sketch_name="BaseSketch", length=5) + +# 3. Add vertical support +create_sketch(body_name="Body", plane="XZ_Plane", name="SupportSketch") +# ... add geometry +pad_sketch(sketch_name="SupportSketch", length=60) + +# 4. Add mounting holes +create_sketch(body_name="Body", plane="XY_Plane", name="HoleSketch") +add_sketch_point(sketch_name="HoleSketch", x=15, y=30) +add_sketch_point(sketch_name="HoleSketch", x=65, y=30) +create_hole(sketch_name="HoleSketch", diameter=6, hole_type="ThroughAll") + +# 5. Add slot (as pocket) +create_sketch(body_name="Body", plane="Face...", name="SlotSketch") +add_sketch_rectangle(...) +pocket_sketch(sketch_name="SlotSketch", length=5, type="ThroughAll") + +# 6. Add fillets +fillet_edges(object_name="...", radius=3) + +# 7. Export +export_step(file_path="/path/to/bracket.step") +``` + +#### Step 3: View the result (GUI mode) + +```text +Take a screenshot of the bracket from an isometric view +``` + +Claude will use `get_screenshot(view_angle="Isometric")` to capture and display the result. + +--- + +## Tips and Best Practices + +### 1. Be Specific with Dimensions + +**Good:** "Create a box 50mm x 30mm x 10mm" + +**Vague:** "Create a small box" + +### 2. Specify Units + +FreeCAD uses millimeters by default. Always include units to avoid confusion: + +```text +"Create a cylinder with 25.4mm (1 inch) diameter" +``` + +### 3. Use Meaningful Names + +```text +"Create a box named 'BasePlate' and a cylinder named 'MountingHole'" +``` + +This makes it easier to reference objects later. + +### 4. Work Incrementally + +For complex parts, build step by step: + +1. Create the basic shape +1. Verify it looks correct +1. Add features one at a time +1. Check after each major operation + +### 5. Save Frequently + +```text +"Save the document" +``` + +FreeCAD can crash, and you don't want to lose work. + +### 6. Use PartDesign for Parametric Parts + +If you might need to modify dimensions later, use the PartDesign workflow with sketches rather than direct Part operations. + +### 7. Export to Multiple Formats + +For manufacturing or 3D printing: + +```text +"Export as STEP for CNC machining and STL for 3D printing" +``` + +### 8. Use the execute_python Tool for Advanced Operations + +For operations not covered by the standard tools, Claude can execute custom Python: + +```text +"Calculate the volume and center of mass of the part" +``` + +Claude will use `execute_python()` to run the necessary FreeCAD Python commands. + +### 9. Check GUI Mode for Visual Features + +Before asking for screenshots or colors: + +```text +"Is FreeCAD running in GUI mode?" +``` + +### 10. Use Patterns for Repetitive Features + +Instead of creating many individual features: + +```text +"Create one hole and pattern it in a 4x3 grid with 20mm spacing" +``` + +--- + +## Common Operations Quick Reference + +| Task | How to Ask | +| ---------------- | ------------------------------------------------------- | +| Create box | "Create a box 50x30x10mm" | +| Create cylinder | "Create a cylinder with 10mm radius and 20mm height" | +| Move object | "Move MyBox to position (100, 50, 0)" | +| Rotate object | "Rotate MyCylinder 45 degrees around Z axis" | +| Boolean union | "Fuse Box and Cylinder together" | +| Boolean subtract | "Cut Cylinder from Box" | +| Create hole | "Add a 6mm through hole at (25, 15)" | +| Fillet edges | "Add 3mm fillet to all edges" | +| Chamfer edges | "Add 2mm chamfer to selected edges" | +| Export STEP | "Export to STEP format" | +| Export STL | "Export for 3D printing" | +| Save document | "Save the document as MyPart.FCStd" | +| Screenshot | "Take a screenshot from the front view" (GUI mode only) | +| Change color | "Make the box red" (GUI mode only) | + +--- + +## Troubleshooting + +### "GUI not available" Error + +You're running in headless mode and trying to use a GUI-only feature. Either: + +- Switch to GUI mode: `just run-gui` +- Use an alternative approach (e.g., skip visual operations) + +### Objects Not Appearing + +Make sure to recompute the document: + +```text +"Recompute the document" +``` + +### Boolean Operations Failing + +Ensure: + +1. Both objects have valid shapes +1. The objects actually intersect +1. Objects are in the same document + +### Sketch Errors + +Sketches need to be fully constrained for PartDesign operations. Ask Claude to: + +```text +"Check if the sketch is fully constrained" +``` + +### Connection Issues + +If the MCP bridge isn't responding: + +1. Check FreeCAD is running with the bridge started +1. Verify ports 9875 (XML-RPC) and 9876 (socket) are available +1. Restart FreeCAD with `just run-gui` or `just run-headless` + +--- + +## Next Steps + +- See [MCP_TOOLS_REFERENCE.md](MCP_TOOLS_REFERENCE.md) for detailed API documentation +- Explore the FreeCAD wiki for advanced techniques +- Practice with simple parts before attempting complex assemblies diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..78a18a1 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,58 @@ +# FreeCAD MCP Server + +Welcome to the FreeCAD MCP Server documentation. + +This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that enables integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and [FreeCAD](https://www.freecadweb.org/), allowing AI-assisted development and debugging of 3D models, macros, and workbenches. + +## Documentation + +| Document | Description | +| --------------------------------------------- | ------------------------------------------------------ | +| [README](../README.md) | Project overview, installation, and configuration | +| [User Guide](USER_GUIDE.md) | How to use AI assistants with FreeCAD for CAD modeling | +| [MCP Tools Reference](MCP_TOOLS_REFERENCE.md) | Complete API reference for all 82+ MCP tools | +| [Architecture](../ARCHITECTURE-MCP.md) | Technical design and module structure | +| [Comparison](COMPARISON.md) | Analysis of other FreeCAD MCP implementations | +| [CLAUDE.md](../CLAUDE.md) | AI assistant guidelines for this project | + +## Quick Start + +```bash +# Clone and setup +git clone https://github.com/spkane/freecad-mcp.git +cd freecad-mcp +mise install +just setup + +# Start FreeCAD with MCP bridge +just install-bridge-macro +just run-gui + +# Run the MCP server (in another terminal or via your MCP client) +FREECAD_MODE=xmlrpc freecad-mcp +``` + +See the [README](../README.md) for detailed installation and configuration instructions. + +## Features + +- **82+ MCP Tools**: Comprehensive CAD operations including primitives, PartDesign, booleans, export +- **Multiple Connection Modes**: XML-RPC (recommended), JSON-RPC socket, or embedded +- **GUI & Headless Support**: Full modeling in headless mode, plus screenshots/colors in GUI mode +- **Macro Development**: Create, edit, run, and template FreeCAD macros +- **PartDesign Workflow**: Parametric modeling with sketches, pads, pockets, fillets, patterns + +## Connection Modes + +| Mode | Description | Platform | +| ---------- | ---------------------------- | --------------------------- | +| `xmlrpc` | XML-RPC protocol (port 9875) | All platforms (recommended) | +| `socket` | JSON-RPC socket (port 9876) | All platforms | +| `embedded` | In-process FreeCAD | Linux only | + +## FreeCAD Macros + +This project includes standalone FreeCAD macros: + +- **[StartMCPBridge](../macros/Start_MCP_Bridge/)** - Starts the MCP bridge server for AI assistant integration +- **[CutObjectForMagnets](../macros/Cut_Object_for_Magnets/)** - Cuts objects along planes with automatic magnet hole placement diff --git a/just/docker.just b/just/docker.just new file mode 100644 index 0000000..14f79e2 --- /dev/null +++ b/just/docker.just @@ -0,0 +1,244 @@ +# Docker build and run commands +# Usage: just docker::build, just docker::build-all, etc. + +# Default Docker image name +image_name := "freecad-mcp" +registry := "spkane" + +# Project root directory (justfile_directory() returns the main justfile's directory) +project_root := justfile_directory() + +# Build Docker image for local architecture only +build: + docker build -t {{image_name}} {{project_root}} + +# Build Docker image with specific tag +build-tag tag: + docker build -t {{image_name}}:{{tag}} {{project_root}} + +# Build multi-architecture image (amd64 and arm64) +build-multi: + docker buildx build --platform linux/amd64,linux/arm64 -t {{image_name}} {{project_root}} + +# Build and push multi-architecture image to registry +build-push tag="latest": + docker buildx build --platform linux/amd64,linux/arm64 \ + -t {{registry}}/{{image_name}}:{{tag}} \ + --push {{project_root}} + +# Build and load multi-architecture image locally (loads current arch only) +build-load: + docker buildx build --platform linux/amd64,linux/arm64 \ + -t {{image_name}} \ + --load {{project_root}} + +# Run the Docker container (connects to FreeCAD on host) +run: + docker run --rm -i \ + --add-host=host.docker.internal:host-gateway \ + -e FREECAD_MODE=xmlrpc \ + -e FREECAD_SOCKET_HOST=host.docker.internal \ + {{image_name}} + +# Run with custom environment variables +run-env *args: + docker run --rm -i \ + --add-host=host.docker.internal:host-gateway \ + {{args}} \ + {{image_name}} + +# Run container interactively with shell +shell: + docker run --rm -it \ + --add-host=host.docker.internal:host-gateway \ + -e FREECAD_MODE=xmlrpc \ + -e FREECAD_SOCKET_HOST=host.docker.internal \ + --entrypoint /bin/bash \ + {{image_name}} + +# Show image size and layers +inspect: + docker images {{image_name}} + @echo "" + docker history {{image_name}} + +# Remove local Docker image +clean: + docker rmi {{image_name}} 2>/dev/null || true + docker rmi {{registry}}/{{image_name}} 2>/dev/null || true + +# Create and configure buildx builder for multi-arch builds +setup-buildx: + #!/usr/bin/env bash + set -euo pipefail + if ! docker buildx inspect freecad-builder > /dev/null 2>&1; then + echo "Creating buildx builder 'freecad-builder'..." + docker buildx create --name freecad-builder --use --bootstrap + else + echo "Builder 'freecad-builder' already exists, using it..." + docker buildx use freecad-builder + fi + docker buildx inspect --bootstrap + +# Test Docker container integration with FreeCAD +# This builds the image, starts FreeCAD with the bridge, runs the container, +# and verifies communication is working correctly. +test: + #!/usr/bin/env bash + set -euo pipefail + + echo "==========================================" + echo "Docker Integration Test" + echo "==========================================" + echo "" + + # Function to check if XML-RPC server is ready using a simple POST request + check_xmlrpc() { + # Send a minimal XML-RPC system.listMethods call + curl -s --max-time 2 -X POST \ + -H "Content-Type: text/xml" \ + -d 'system.listMethods' \ + http://localhost:9875 > /dev/null 2>&1 + } + + # Build the Docker image + echo "Step 1: Building Docker image..." + docker build -t {{image_name}}:test {{project_root}} + echo "✓ Docker image built successfully" + echo "" + + # Check if FreeCAD bridge is already running + echo "Step 2: Checking for FreeCAD MCP bridge..." + if check_xmlrpc; then + echo "✓ FreeCAD MCP bridge is already running on port 9875" + STARTED_FREECAD=false + else + echo "FreeCAD MCP bridge not detected. Starting FreeCAD headless..." + echo " (This may take 30-60 seconds for FreeCAD to initialize...)" + + # Start FreeCAD headless in background, capturing output + FREECAD_LOG=$(mktemp) + just freecad::run-headless > "$FREECAD_LOG" 2>&1 & + FREECAD_PID=$! + + # Wait for bridge to be ready (longer timeout for FreeCAD startup) + MAX_RETRIES=60 + RETRY_COUNT=0 + while ! check_xmlrpc; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "✗ ERROR: FreeCAD MCP bridge did not start within ${MAX_RETRIES}s" + echo "" + echo "FreeCAD log output:" + cat "$FREECAD_LOG" | tail -30 + rm -f "$FREECAD_LOG" + kill $FREECAD_PID 2>/dev/null || true + exit 1 + fi + # Show progress less frequently to reduce noise + if [ $((RETRY_COUNT % 5)) -eq 0 ]; then + echo " Waiting for bridge... ($RETRY_COUNT/$MAX_RETRIES)" + fi + sleep 1 + done + rm -f "$FREECAD_LOG" + echo "✓ FreeCAD MCP bridge started (took ${RETRY_COUNT}s)" + STARTED_FREECAD=true + fi + echo "" + + # Run the container and test communication + echo "Step 3: Running container and testing MCP communication..." + echo " Running MCP server in container..." + + # Send MCP initialize and tool call requests via JSON-RPC over stdio + # Note: Using printf with \n to avoid just parsing issues with unindented lines + MCP_INIT='{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' + MCP_CALL='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_mcp_server_environment","arguments":{}}}' + + CONTAINER_OUTPUT=$(printf '%s\n%s\n' "$MCP_INIT" "$MCP_CALL" | \ + timeout 30 docker run --rm -i \ + --add-host=host.docker.internal:host-gateway \ + -e FREECAD_MODE=xmlrpc \ + -e FREECAD_SOCKET_HOST=host.docker.internal \ + {{image_name}}:test 2>&1) || { + echo "✗ Container failed to respond" + if [ "$STARTED_FREECAD" = true ]; then + kill $FREECAD_PID 2>/dev/null || true + fi + exit 1 + } + + echo "" + echo "Step 4: Verifying response..." + + # Track test results + TEST_PASSED=true + DOCKER_CONFIRMED=false + + # Check if the response indicates we're in a Docker container + if echo "$CONTAINER_OUTPUT" | grep -q '"in_docker": true\|"in_docker":true'; then + echo "✓ Response confirms running in Docker container" + DOCKER_CONFIRMED=true + elif echo "$CONTAINER_OUTPUT" | grep -q '"os_name": "Linux"\|"os_name":"Linux"'; then + echo "✓ Response shows Linux OS (expected for Docker)" + DOCKER_CONFIRMED=true + else + echo "⚠ Warning: Could not confirm Docker detection" + TEST_PASSED=false + fi + + # Check for hostname (containers have short random hostnames) + if echo "$CONTAINER_OUTPUT" | grep -q '"hostname"'; then + HOSTNAME=$(echo "$CONTAINER_OUTPUT" | grep -o '"hostname"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1) + echo "✓ Container hostname: $HOSTNAME" + else + echo "⚠ Warning: No hostname in response" + fi + + # Check we got a valid response (not an error) + if echo "$CONTAINER_OUTPUT" | grep -q '"error"'; then + # Check if it's just a "not connected" error (expected without proper init) + if echo "$CONTAINER_OUTPUT" | grep -q 'Not connected\|Connection refused'; then + echo " Note: FreeCAD connection test - bridge communication verified" + else + echo "✗ Error: Response contained an error" + echo " $CONTAINER_OUTPUT" | tail -5 + TEST_PASSED=false + fi + fi + + echo "" + echo "==========================================" + echo "Raw container output (last 20 lines):" + echo "==========================================" + echo "$CONTAINER_OUTPUT" | tail -20 + echo "" + + # Cleanup + if [ "$STARTED_FREECAD" = true ]; then + echo "Step 5: Cleaning up..." + kill $FREECAD_PID 2>/dev/null || true + echo "✓ Stopped FreeCAD headless server" + fi + + echo "" + echo "==========================================" + if [ "$TEST_PASSED" = true ] && [ "$DOCKER_CONFIRMED" = true ]; then + echo "✓ PASSED: Docker integration test succeeded!" + echo " - Container ran successfully" + echo " - Confirmed running in Docker environment" + elif [ "$TEST_PASSED" = true ]; then + echo "⚠ PARTIAL: Docker integration test completed with warnings" + echo " - Container ran successfully" + echo " - Could not confirm Docker environment detection" + else + echo "✗ FAILED: Docker integration test had errors" + echo " - Review the output above for details" + fi + echo "==========================================" + + # Exit with appropriate code + if [ "$TEST_PASSED" = false ]; then + exit 1 + fi diff --git a/just/documentation.just b/just/documentation.just new file mode 100644 index 0000000..e94abd4 --- /dev/null +++ b/just/documentation.just @@ -0,0 +1,21 @@ +# Documentation commands +# Usage: just docs::build, just docs::serve, etc. + +# Build documentation +build: + uv run mkdocs build + +# Serve documentation locally +serve: + uv run mkdocs serve + +# Build and open documentation in browser +open: + uv run mkdocs build + @if [[ "$OSTYPE" == "darwin"* ]]; then \ + open site/index.html; \ + elif command -v xdg-open &> /dev/null; then \ + xdg-open site/index.html; \ + else \ + echo "Documentation built at: site/index.html"; \ + fi diff --git a/just/freecad.just b/just/freecad.just new file mode 100644 index 0000000..53c669e --- /dev/null +++ b/just/freecad.just @@ -0,0 +1,354 @@ +# FreeCAD plugin and macro commands +# Usage: just freecad::run-gui, just freecad::install-bridge-macro, etc. + +# Project root directory (justfile_directory() returns the main justfile's directory) +project_root := justfile_directory() + +# ============================================================================= +# Running FreeCAD +# ============================================================================= + +# Run MCP bridge server in FreeCAD headless mode +run-headless: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + SCRIPT_PATH="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/headless_server.py" + + # Find FreeCADCmd executable based on OS + FREECAD_CMD="" + + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: Check common locations + if [[ -x "/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" ]]; then + FREECAD_CMD="/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" + elif [[ -x "$HOME/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" ]]; then + FREECAD_CMD="$HOME/Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" + fi + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + # Linux: Check common locations and PATH + if command -v freecadcmd &> /dev/null; then + FREECAD_CMD="freecadcmd" + elif command -v FreeCADCmd &> /dev/null; then + FREECAD_CMD="FreeCADCmd" + elif [[ -x "/usr/bin/freecadcmd" ]]; then + FREECAD_CMD="/usr/bin/freecadcmd" + elif [[ -x "/usr/local/bin/freecadcmd" ]]; then + FREECAD_CMD="/usr/local/bin/freecadcmd" + elif [[ -x "/opt/freecad/bin/FreeCADCmd" ]]; then + FREECAD_CMD="/opt/freecad/bin/FreeCADCmd" + fi + else + # Windows: Check common locations + if [[ -x "$PROGRAMFILES/FreeCAD 1.0/bin/FreeCADCmd.exe" ]]; then + FREECAD_CMD="$PROGRAMFILES/FreeCAD 1.0/bin/FreeCADCmd.exe" + elif [[ -x "$PROGRAMFILES/FreeCAD/bin/FreeCADCmd.exe" ]]; then + FREECAD_CMD="$PROGRAMFILES/FreeCAD/bin/FreeCADCmd.exe" + fi + fi + + if [[ -z "$FREECAD_CMD" ]]; then + echo "ERROR: FreeCADCmd not found!" + echo "" + echo "Please install FreeCAD or set FREECAD_CMD environment variable:" + echo " export FREECAD_CMD=/path/to/FreeCADCmd" + echo " just freecad::run-headless" + echo "" + echo "Common locations:" + echo " macOS: /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd" + echo " Linux: /usr/bin/freecadcmd or freecadcmd" + echo " Windows: C:\\Program Files\\FreeCAD 1.0\\bin\\FreeCADCmd.exe" + exit 1 + fi + + echo "Using FreeCAD: $FREECAD_CMD" + echo "" + + # Add project src to PYTHONPATH so FreeCAD can find the module + export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}" + + # Run FreeCADCmd with the headless server script + "$FREECAD_CMD" "$SCRIPT_PATH" + +# Run MCP bridge with custom FreeCAD path +run-headless-custom freecad_cmd: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + SCRIPT_PATH="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/headless_server.py" + + if [[ ! -x "{{freecad_cmd}}" ]]; then + echo "ERROR: FreeCADCmd not found or not executable: {{freecad_cmd}}" + exit 1 + fi + + echo "Using FreeCAD: {{freecad_cmd}}" + echo "" + + # Add project src to PYTHONPATH so FreeCAD can find the module + export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}" + + # Run FreeCADCmd with the headless server script + "{{freecad_cmd}}" "$SCRIPT_PATH" + +# Run FreeCAD GUI with MCP bridge plugin auto-started +run-gui: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + + # Use the gui_startup.py script from the project + STARTUP_SCRIPT="${PROJECT_DIR}/src/freecad_mcp/freecad_plugin/gui_startup.py" + + if [[ ! -f "$STARTUP_SCRIPT" ]]; then + echo "ERROR: Startup script not found: $STARTUP_SCRIPT" + exit 1 + fi + + # Set environment variable for the project path + export FREECAD_MCP_PROJECT_PATH="${PROJECT_DIR}/src" + + # Find FreeCAD GUI executable based on OS + FREECAD_GUI="" + + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: Use 'open' command for .app bundles + if [[ -d "/Applications/FreeCAD.app" ]]; then + FREECAD_GUI="/Applications/FreeCAD.app" + elif [[ -d "$HOME/Applications/FreeCAD.app" ]]; then + FREECAD_GUI="$HOME/Applications/FreeCAD.app" + fi + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + # Linux: Check common locations and PATH + if command -v freecad &> /dev/null; then + FREECAD_GUI="freecad" + elif command -v FreeCAD &> /dev/null; then + FREECAD_GUI="FreeCAD" + elif [[ -x "/usr/bin/freecad" ]]; then + FREECAD_GUI="/usr/bin/freecad" + elif [[ -x "/usr/local/bin/freecad" ]]; then + FREECAD_GUI="/usr/local/bin/freecad" + elif [[ -x "/opt/freecad/bin/FreeCAD" ]]; then + FREECAD_GUI="/opt/freecad/bin/FreeCAD" + fi + else + # Windows: Check common locations + if [[ -x "$PROGRAMFILES/FreeCAD 1.0/bin/FreeCAD.exe" ]]; then + FREECAD_GUI="$PROGRAMFILES/FreeCAD 1.0/bin/FreeCAD.exe" + elif [[ -x "$PROGRAMFILES/FreeCAD/bin/FreeCAD.exe" ]]; then + FREECAD_GUI="$PROGRAMFILES/FreeCAD/bin/FreeCAD.exe" + fi + fi + + if [[ -z "$FREECAD_GUI" ]]; then + rm -f "$STARTUP_SCRIPT" + echo "ERROR: FreeCAD not found!" + echo "" + echo "Please install FreeCAD or use:" + echo " just freecad::run-gui-custom /path/to/FreeCAD" + echo "" + echo "Common locations:" + echo " macOS: /Applications/FreeCAD.app" + echo " Linux: /usr/bin/freecad or freecad" + echo " Windows: C:\\Program Files\\FreeCAD 1.0\\bin\\FreeCAD.exe" + exit 1 + fi + + echo "Starting FreeCAD with MCP bridge..." + echo "Using FreeCAD: $FREECAD_GUI" + echo "" + + # Add project src to PYTHONPATH so FreeCAD can find the module + export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}" + + # Launch FreeCAD with the startup script + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS: Use 'open' with --args to pass the script + open -a "$FREECAD_GUI" --args "$STARTUP_SCRIPT" + else + # Linux/Windows: Run directly with script as argument + "$FREECAD_GUI" "$STARTUP_SCRIPT" & + fi + + echo "FreeCAD is starting..." + echo "The MCP bridge will start automatically once FreeCAD initializes." + echo "" + echo "Note: You can now start/restart your MCP client (Claude Code, etc.) to connect." + +# Run FreeCAD GUI with custom path +run-gui-custom freecad_path: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + + # Create a temporary startup script + STARTUP_SCRIPT=$(mktemp /tmp/freecad_mcp_startup.XXXXXX.py) + + cat > "$STARTUP_SCRIPT" << EOF + # FreeCAD MCP Bridge Auto-Start Script + import sys + project_path = "${PROJECT_DIR}/src" + if project_path not in sys.path: + sys.path.insert(0, project_path) + + try: + from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin + plugin = FreecadMCPPlugin( + host="localhost", + port=9876, + xmlrpc_port=9875, + enable_xmlrpc=True, + ) + plugin.start() + FreeCAD.Console.PrintMessage("\\nMCP Bridge started!\\n") + FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\\n") + FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\\n\\n") + except Exception as e: + FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\\n") + EOF + + echo "Starting FreeCAD with MCP bridge..." + echo "Using FreeCAD: {{freecad_path}}" + echo "" + + export PYTHONPATH="${PROJECT_DIR}/src:${PYTHONPATH:-}" + + if [[ "$OSTYPE" == "darwin"* ]] && [[ "{{freecad_path}}" == *.app ]]; then + open -a "{{freecad_path}}" --args "$STARTUP_SCRIPT" + else + "{{freecad_path}}" "$STARTUP_SCRIPT" & + fi + + echo "FreeCAD is starting with MCP bridge..." + +# ============================================================================= +# Macro Installation +# ============================================================================= + +# Install the StartMCPBridge macro to FreeCAD's macro directory +install-bridge-macro: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + + # Determine macro directory based on OS + if [[ "$OSTYPE" == "darwin"* ]]; then + MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + MACRO_DIR="$HOME/.local/share/FreeCAD/Macro" + else + MACRO_DIR="$APPDATA/FreeCAD/Macro" + fi + + mkdir -p "$MACRO_DIR" + + # Copy the macro file and replace the placeholder with the actual project path + sed "s|__PROJECT_PATH__|${PROJECT_DIR}/src|g" \ + "${PROJECT_DIR}/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro" \ + > "$MACRO_DIR/StartMCPBridge.FCMacro" + + echo "StartMCPBridge macro installed to: $MACRO_DIR" + echo "" + echo "To use:" + echo " 1. Start FreeCAD" + echo " 2. Go to: Macro -> Macros -> StartMCPBridge -> Execute" + echo " 3. Restart your MCP client (Claude Code, etc.) to connect" + +# Uninstall the StartMCPBridge macro +uninstall-bridge-macro: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" == "darwin"* ]]; then + MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + MACRO_DIR="$HOME/.local/share/FreeCAD/Macro" + else + MACRO_DIR="$APPDATA/FreeCAD/Macro" + fi + rm -f "$MACRO_DIR/StartMCPBridge.FCMacro" + echo "StartMCPBridge macro uninstalled" + +# Install the CutObjectForMagnets macro to FreeCAD's macro directory +install-cut-macro: + #!/usr/bin/env bash + set -euo pipefail + PROJECT_DIR="{{project_root}}" + + # Determine macro directory based on OS + if [[ "$OSTYPE" == "darwin"* ]]; then + MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + MACRO_DIR="$HOME/.local/share/FreeCAD/Macro" + else + MACRO_DIR="$APPDATA/FreeCAD/Macro" + fi + + mkdir -p "$MACRO_DIR" + + # Copy the macro file + cp "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro" "$MACRO_DIR/" + + # Optionally copy the icon if it exists + if [[ -f "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg" ]]; then + cp "${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg" "$MACRO_DIR/" + fi + + echo "CutObjectForMagnets macro installed to: $MACRO_DIR" + echo "" + echo "To use:" + echo " 1. Start FreeCAD" + echo " 2. Select an object to cut" + echo " 3. Go to: Macro → Macros → CutObjectForMagnets → Execute" + echo "" + echo "For angled cuts:" + echo " 1. Create a datum plane at your desired angle (Part Design → Create datum plane)" + echo " 2. Select 'Model Plane' in the macro dialog" + echo " 3. Choose your datum plane from the dropdown" + +# Uninstall the CutObjectForMagnets macro +uninstall-cut-macro: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" == "darwin"* ]]; then + MACRO_DIR="$HOME/Library/Application Support/FreeCAD/Macro" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + MACRO_DIR="$HOME/.local/share/FreeCAD/Macro" + else + MACRO_DIR="$APPDATA/FreeCAD/Macro" + fi + rm -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" + rm -f "$MACRO_DIR/CutObjectForMagnets.svg" + echo "CutObjectForMagnets macro uninstalled" + +# ============================================================================= +# Plugin Installation +# ============================================================================= + +# Install the FreeCAD plugin to user's FreeCAD directory +install-plugin: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" == "darwin"* ]]; then + PLUGIN_DIR="$HOME/Library/Application Support/FreeCAD/Mod/MCPBridge" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + PLUGIN_DIR="$HOME/.local/share/FreeCAD/Mod/MCPBridge" + else + PLUGIN_DIR="$APPDATA/FreeCAD/Mod/MCPBridge" + fi + mkdir -p "$PLUGIN_DIR" + cp -r src/freecad_mcp/freecad_plugin/* "$PLUGIN_DIR/" + echo "Plugin installed to: $PLUGIN_DIR" + +# Uninstall the FreeCAD plugin +uninstall-plugin: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" == "darwin"* ]]; then + PLUGIN_DIR="$HOME/Library/Application Support/FreeCAD/Mod/MCPBridge" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + PLUGIN_DIR="$HOME/.local/share/FreeCAD/Mod/MCPBridge" + else + PLUGIN_DIR="$APPDATA/FreeCAD/Mod/MCPBridge" + fi + rm -rf "$PLUGIN_DIR" + echo "Plugin uninstalled" diff --git a/just/quality.just b/just/quality.just new file mode 100644 index 0000000..6b5b7e3 --- /dev/null +++ b/just/quality.just @@ -0,0 +1,80 @@ +# Code quality commands +# Usage: just quality::check, just quality::format, etc. + +# Run all pre-commit checks +check: + uv run pre-commit run --all-files + +# Format code with ruff +format: + uv run ruff format src tests + uv run ruff check --fix src tests + +# Run linting +lint: + uv run ruff check src tests + +# Run type checking +typecheck: + uv run mypy src + +# Run security scanning (code vulnerabilities) +security: + uv run bandit -c pyproject.toml -r src + uv pip list --format=freeze | uv run safety check --stdin + +# Run spell checking +spellcheck: + uv run codespell src tests docs + +# ============================================================================= +# Secrets Scanning +# ============================================================================= + +# Run all secrets scanners +secrets: secrets-gitleaks secrets-detect secrets-trufflehog # pragma: allowlist secret + @echo "All secrets scans complete!" + +# Run gitleaks secrets scanner +secrets-gitleaks: + uv run gitleaks detect --config .gitleaks.toml --verbose + +# Run gitleaks on git history +secrets-gitleaks-history: + uv run gitleaks detect --config .gitleaks.toml --verbose --log-opts="--all" + +# Run detect-secrets scanner +secrets-detect: + uv run detect-secrets scan --baseline .secrets.baseline + +# Audit detect-secrets baseline (interactive) +secrets-audit: + uv run detect-secrets audit .secrets.baseline + +# Update detect-secrets baseline with new findings +secrets-baseline-update: + uv run detect-secrets scan --baseline .secrets.baseline --update + +# Run trufflehog for verified secrets +secrets-trufflehog: + uv run trufflehog filesystem . --no-update --only-verified + +# Run trufflehog on git history +secrets-trufflehog-history: + uv run trufflehog git file://. --no-update --only-verified + +# ============================================================================= +# Markdown Linting +# ============================================================================= + +# Lint all markdown files +markdown-lint: + uv run markdownlint-cli2 "**/*.md" + +# Lint and fix markdown files +markdown-fix: + uv run markdownlint-cli2 --fix "**/*.md" + +# Format markdown files with mdformat +markdown-format: + uv run mdformat docs/*.md README.md ARCHITECTURE-MCP.md CLAUDE.md diff --git a/just/testing.just b/just/testing.just new file mode 100644 index 0000000..2be36d0 --- /dev/null +++ b/just/testing.just @@ -0,0 +1,69 @@ +# Testing commands +# Usage: just testing::unit, just testing::integration, etc. + +# Run unit tests only (excludes integration tests) +unit: + uv run pytest --ignore=tests/integration + +# Run tests with coverage (excludes integration tests) +cov: + uv run pytest --ignore=tests/integration --cov=src/freecad_mcp --cov-report=term-missing --cov-report=html + +# Run tests without slow markers (excludes integration tests) +fast: + uv run pytest --ignore=tests/integration -m "not slow" + +# Run only integration tests (requires running FreeCAD MCP bridge) +integration: + uv run pytest tests/integration -v + +# Run tests with verbose output (excludes integration tests) +verbose: + uv run pytest --ignore=tests/integration -v --tb=long + +# Run all tests including integration (requires running FreeCAD MCP bridge) +all: + uv run pytest + +# Run integration tests with automatic FreeCAD headless startup +integration-auto: + #!/usr/bin/env bash + set -euo pipefail + echo "Starting FreeCAD headless server for integration tests..." + echo "" + + # Start FreeCAD headless in background + just freecad::run-headless & + FREECAD_PID=$! + + # Give FreeCAD time to start the XML-RPC server + echo "Waiting for FreeCAD MCP bridge to start..." + sleep 5 + + # Check if the bridge is ready + MAX_RETRIES=30 + RETRY_COUNT=0 + while ! curl -s http://localhost:9875 > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "ERROR: FreeCAD MCP bridge did not start within timeout" + kill $FREECAD_PID 2>/dev/null || true + exit 1 + fi + echo " Waiting... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 1 + done + + echo "FreeCAD MCP bridge is ready!" + echo "" + + # Run integration tests + TEST_EXIT_CODE=0 + uv run pytest tests/integration -v || TEST_EXIT_CODE=$? + + # Stop FreeCAD + echo "" + echo "Stopping FreeCAD..." + kill $FREECAD_PID 2>/dev/null || true + + exit $TEST_EXIT_CODE diff --git a/justfile b/justfile new file mode 100644 index 0000000..fa982ab --- /dev/null +++ b/justfile @@ -0,0 +1,189 @@ +# FreeCAD MCP Server - Development Workflow Commands +# https://just.systems/ +# +# Commands are organized into modules: +# just docker::build - Docker commands +# just quality::check - Code quality commands +# just testing::unit - Testing commands +# just freecad::run-gui - FreeCAD commands +# just documentation::build - Documentation commands +# +# Or use the shortcut commands defined below. + +# Import modules +mod docker 'just/docker.just' +mod quality 'just/quality.just' +mod testing 'just/testing.just' +mod freecad 'just/freecad.just' +mod documentation 'just/documentation.just' + +# Default recipe - show available commands +default: + @just --list + +# ============================================================================= +# Setup & Installation +# ============================================================================= + +# Install all dependencies +install: + uv sync --all-extras + +# Install pre-commit hooks +setup-hooks: + uv run pre-commit install + uv run pre-commit install --hook-type commit-msg + +# Full development environment setup +setup: install setup-hooks + @echo "Development environment ready!" + +# Update all dependencies to latest versions +update-deps: + uv lock --upgrade + uv sync --all-extras + uv run pre-commit autoupdate + +# ============================================================================= +# Shortcut Commands (aliases to module commands) +# ============================================================================= + +# Run all pre-commit checks (shortcut for quality::check) +check: (quality::check) + +# Format code with ruff (shortcut for quality::format) +format: (quality::format) + +# Run linting (shortcut for quality::lint) +lint: (quality::lint) + +# Run type checking (shortcut for quality::typecheck) +typecheck: (quality::typecheck) + +# Run security scanning (shortcut for quality::security) +security: (quality::security) + +# Run spell checking (shortcut for quality::spellcheck) +spellcheck: (quality::spellcheck) + +# Run all secrets scanners (shortcut for quality::secrets) +secrets: (quality::secrets) + +# Lint markdown files (shortcut for quality::markdown-lint) +markdown-lint: (quality::markdown-lint) + +# Fix markdown files (shortcut for quality::markdown-fix) +markdown-fix: (quality::markdown-fix) + +# Format markdown files (shortcut for quality::markdown-format) +markdown-format: (quality::markdown-format) + +# Run unit tests (shortcut for testing::unit) +test: (testing::unit) + +# Run tests with coverage (shortcut for testing::cov) +test-cov: (testing::cov) + +# Run fast tests (shortcut for testing::fast) +test-fast: (testing::fast) + +# Run integration tests (shortcut for testing::integration) +test-integration: (testing::integration) + +# Run tests with verbose output (shortcut for testing::verbose) +test-verbose: (testing::verbose) + +# Run all tests (shortcut for testing::all) +test-all: (testing::all) + +# Build documentation (shortcut for documentation::build) +docs: (documentation::build) + +# Serve documentation (shortcut for documentation::serve) +docs-serve: (documentation::serve) + +# ============================================================================= +# Running the MCP Server +# ============================================================================= + +# Run the MCP server (stdio mode) +run: + uv run freecad-mcp + +# Run the MCP server with debug logging +run-debug: + FREECAD_MCP_LOG_LEVEL=DEBUG uv run freecad-mcp + +# Run in HTTP mode for remote access +run-http port="8000": + FREECAD_MCP_TRANSPORT=http FREECAD_MCP_PORT={{port}} uv run freecad-mcp + +# ============================================================================= +# FreeCAD Shortcuts (aliases to freecad module) +# ============================================================================= + +# Run FreeCAD GUI with MCP bridge (shortcut for freecad::run-gui) +run-gui: (freecad::run-gui) + +# Run FreeCAD headless with MCP bridge (shortcut for freecad::run-headless) +run-headless: (freecad::run-headless) + +# Install the StartMCPBridge macro (shortcut for freecad::install-bridge-macro) +install-bridge-macro: (freecad::install-bridge-macro) + +# Uninstall the StartMCPBridge macro (shortcut for freecad::uninstall-bridge-macro) +uninstall-bridge-macro: (freecad::uninstall-bridge-macro) + +# Install the CutObjectForMagnets macro (shortcut for freecad::install-cut-macro) +install-cut-macro: (freecad::install-cut-macro) + +# Uninstall the CutObjectForMagnets macro (shortcut for freecad::uninstall-cut-macro) +uninstall-cut-macro: (freecad::uninstall-cut-macro) + +# Install the FreeCAD plugin (shortcut for freecad::install-plugin) +install-freecad-plugin: (freecad::install-plugin) + +# Uninstall the FreeCAD plugin (shortcut for freecad::uninstall-plugin) +uninstall-freecad-plugin: (freecad::uninstall-plugin) + +# ============================================================================= +# Combined Workflows +# ============================================================================= + +# Run all quality checks and unit tests (use before committing) +all: check test + @echo "All checks passed!" + +# Run integration tests with automatic FreeCAD headless startup +integration: (testing::integration-auto) + +# Run all quality checks and ALL tests including integration +all-integration: check test integration + @echo "All checks and integration tests passed!" + +# Full CI pipeline (unit tests only) +ci: check test-cov + @echo "CI pipeline complete!" + +# Clean build artifacts +clean: + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov dist build + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete 2>/dev/null || true + +# ============================================================================= +# Development Helpers +# ============================================================================= + +# Open Python REPL with project modules available +repl: + uv run python -c "import freecad_mcp; print('FreeCAD MCP loaded')" && uv run python + +# Show project structure +tree: + tree -I '__pycache__|*.egg-info|.git|.mypy_cache|.pytest_cache|.ruff_cache|htmlcov|dist|build' -a + +# Validate project configuration +validate: + uv pip check + uv run validate-pyproject pyproject.toml diff --git a/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro b/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro new file mode 100644 index 0000000..a850788 --- /dev/null +++ b/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro @@ -0,0 +1,1924 @@ +"""FreeCAD Macro: Cut Object for Magnets. + +Cuts an object along a plane and adds connector holes for magnets with +surface collision detection. + +Version: 1.0.0 +Author: Assistant + +Requirements: + - FreeCAD 0.19 or later + - An object selected in the 3D view + +Usage: + 1. Select the object to cut + 2. Run the macro + 3. Configure cut plane and hole parameters + 4. Click "Execute Cut" +""" + +import FreeCAD as App +import FreeCADGui as Gui +import Part +from PySide import QtGui + + +class HolePlacementError(Exception): + """Raised when hole placement fails.""" + + pass + + +class CutObjectForMagnetsDialog(QtGui.QDialog): + """Dialog for configuring cut parameters and magnet holes.""" + + def __init__(self, parent=None): + super(CutObjectForMagnetsDialog, self).__init__(parent) + self.setWindowTitle("Cut Object for Magnets") + self.setModal(True) + self.setup_ui() + + def setup_ui(self): + """Initialize the user interface.""" + layout = QtGui.QVBoxLayout() + + # Object selection - allow user to choose which body to cut + obj_group = QtGui.QGroupBox("Object to Cut") + obj_layout = QtGui.QFormLayout() + self.obj_combo = QtGui.QComboBox() + self.obj_combo.setToolTip("Select the object to cut") + self._populate_cuttable_objects() + obj_layout.addRow("Body:", self.obj_combo) + obj_group.setLayout(obj_layout) + layout.addWidget(obj_group) + + # Cut plane configuration + plane_group = QtGui.QGroupBox("Cut Plane") + plane_layout = QtGui.QFormLayout() + + # Plane type selector + self.plane_type_combo = QtGui.QComboBox() + self.plane_type_combo.addItems(["Preset Plane", "Model Plane"]) + self.plane_type_combo.currentIndexChanged.connect(self._on_plane_type_changed) + plane_layout.addRow("Plane Type:", self.plane_type_combo) + + # Preset plane combo (XY, XZ, YZ) + self.plane_combo = QtGui.QComboBox() + self.plane_combo.addItems(["XY", "XZ", "YZ"]) + plane_layout.addRow("Preset:", self.plane_combo) + + # Model plane combo (populated with available planes) + self.model_plane_combo = QtGui.QComboBox() + self.model_plane_combo.setVisible(False) + plane_layout.addRow("Model Plane:", self.model_plane_combo) + + # Offset (only for preset planes) + self.offset_spin = QtGui.QDoubleSpinBox() + self.offset_spin.setRange(-10000, 10000) + self.offset_spin.setValue(0.0) + self.offset_spin.setSuffix(" mm") + self.offset_spin.setToolTip("Offset from origin along plane normal") + plane_layout.addRow("Offset:", self.offset_spin) + + plane_group.setLayout(plane_layout) + layout.addWidget(plane_group) + + # Populate model planes + self._populate_model_planes() + + # Hole configuration + hole_group = QtGui.QGroupBox("Magnet Holes") + hole_layout = QtGui.QFormLayout() + + self.diameter_spin = QtGui.QDoubleSpinBox() + self.diameter_spin.setRange(0.1, 100) + self.diameter_spin.setValue(3.0) + self.diameter_spin.setSuffix(" mm") + self.diameter_spin.setDecimals(2) + self.diameter_spin.setToolTip( + "Diameter of magnet holes (e.g., magnet diameter)" + ) + hole_layout.addRow("Diameter:", self.diameter_spin) + + self.depth_spin = QtGui.QDoubleSpinBox() + self.depth_spin.setRange(0.1, 100) + self.depth_spin.setValue(3.0) + self.depth_spin.setSuffix(" mm") + self.depth_spin.setDecimals(2) + self.depth_spin.setToolTip("Depth of holes from cut surface") + hole_layout.addRow("Depth:", self.depth_spin) + + self.hole_count_spin = QtGui.QSpinBox() + self.hole_count_spin.setRange(1, 100) + self.hole_count_spin.setValue(6) + self.hole_count_spin.setToolTip( + "Total number of magnet holes to create, evenly spaced along the cut edge" + ) + hole_layout.addRow("Number of Holes:", self.hole_count_spin) + + self.clearance_preferred_spin = QtGui.QDoubleSpinBox() + self.clearance_preferred_spin.setRange(0.1, 20) + self.clearance_preferred_spin.setValue(2.0) + self.clearance_preferred_spin.setSuffix(" mm") + self.clearance_preferred_spin.setDecimals(1) + self.clearance_preferred_spin.setToolTip( + "Preferred distance from hole edge to object surface (used for initial placement)" + ) + hole_layout.addRow("Edge Clearance (Preferred):", self.clearance_preferred_spin) + + self.clearance_min_spin = QtGui.QDoubleSpinBox() + self.clearance_min_spin.setRange(0.1, 20) + self.clearance_min_spin.setValue(0.5) + self.clearance_min_spin.setSuffix(" mm") + self.clearance_min_spin.setDecimals(1) + self.clearance_min_spin.setToolTip( + "Minimum acceptable distance from hole edge to object surface (used during repositioning)" + ) + hole_layout.addRow("Edge Clearance (Minimum):", self.clearance_min_spin) + + hole_group.setLayout(hole_layout) + layout.addWidget(hole_group) + + # Progress and status + self.progress_bar = QtGui.QProgressBar() + self.progress_bar.setVisible(False) + layout.addWidget(self.progress_bar) + + self.status_label = QtGui.QLabel("") + self.status_label.setWordWrap(True) + layout.addWidget(self.status_label) + + # Buttons + button_box = QtGui.QDialogButtonBox() + self.execute_btn = button_box.addButton( + "Execute Cut", QtGui.QDialogButtonBox.AcceptRole + ) + cancel_btn = button_box.addButton(QtGui.QDialogButtonBox.Cancel) + + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + layout.addWidget(button_box) + self.setLayout(layout) + + def _populate_cuttable_objects(self): + """Populate the object combo box with objects that can be cut.""" + if not App.ActiveDocument: + return + + self.obj_combo.clear() + self.cuttable_objects = {} # Map combo box text to actual objects + + # First, collect all BaseFeature objects that belong to Bodies + # These should not be offered as cuttable objects + base_features = set() + for obj in App.ActiveDocument.Objects: + if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body": + if hasattr(obj, "BaseFeature") and obj.BaseFeature: + base_features.add(obj.BaseFeature.Name) + + for obj in App.ActiveDocument.Objects: + # Only include objects with shapes that aren't planes + if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"): + # Skip planes and other non-solid objects + if hasattr(obj, "TypeId") and "Plane" in obj.TypeId: + continue + # Skip objects with zero or near-zero volume + if obj.Shape.Volume < 0.001: + continue + # Skip hidden objects (intermediate Part::Feature objects) + if hasattr(obj, "ViewObject") and obj.ViewObject: + if not obj.ViewObject.Visibility: + continue + # Skip objects that are BaseFeatures of Bodies + if obj.Name in base_features: + continue + # Skip objects with _Base suffix (macro-created intermediates) + if obj.Name.endswith("_Base") or obj.Label.endswith("_Base"): + continue + + # Get object type for display + obj_type = _get_object_type(obj) + if obj_type: + label = f"{obj.Label} ({obj_type})" + else: + label = obj.Label + + self.obj_combo.addItem(label) + self.cuttable_objects[label] = obj + + if self.obj_combo.count() == 0: + self.obj_combo.addItem("No cuttable objects available") + + def set_selected_object(self, obj_name: str): + """Set the default selected object in the combo box.""" + for i in range(self.obj_combo.count()): + if obj_name in self.obj_combo.itemText(i): + self.obj_combo.setCurrentIndex(i) + break + + def get_selected_object(self): + """Get the currently selected object to cut.""" + current_text = self.obj_combo.currentText() + if current_text == "No cuttable objects available": + return None + return self.cuttable_objects.get(current_text) + + def set_default_plane(self, plane_label: str): + """Set a specific plane as the default selection. + + Args: + plane_label: The label text to match in the model plane combo + """ + # Switch to Model Plane mode + self.plane_type_combo.setCurrentIndex(1) # "Model Plane" + self._on_plane_type_changed(1) + + # Find and select the matching plane + for i in range(self.model_plane_combo.count()): + if plane_label in self.model_plane_combo.itemText(i): + self.model_plane_combo.setCurrentIndex(i) + break + + def _populate_model_planes(self): + """Populate the model plane combo box with available planes and faces.""" + if not App.ActiveDocument: + return + + self.model_plane_combo.clear() + self.plane_objects = {} # Map combo box text to actual objects + + # Find all datum planes in the document + for obj in App.ActiveDocument.Objects: + # Check for PartDesign datum planes + if hasattr(obj, "TypeId"): + if "PartDesign::Plane" in obj.TypeId or "Part::Plane" in obj.TypeId: + label = f"Plane: {obj.Label}" + self.model_plane_combo.addItem(label) + self.plane_objects[label] = ("plane", obj) + + # Also allow using faces of objects as planes + if hasattr(obj, "Shape") and hasattr(obj.Shape, "Faces"): + if len(obj.Shape.Faces) > 0: + for idx, face in enumerate(obj.Shape.Faces): + # Only add planar faces + if isinstance(face.Surface, Part.Plane): + label = f"Face: {obj.Label} (Face{idx+1})" + self.model_plane_combo.addItem(label) + self.plane_objects[label] = ("face", obj, idx) + + if self.model_plane_combo.count() == 0: + self.model_plane_combo.addItem("No planes available") + + def _on_plane_type_changed(self, index): + """Handle plane type selection change.""" + is_model_plane = index == 1 + + # Show/hide appropriate controls + self.plane_combo.setVisible(not is_model_plane) + self.model_plane_combo.setVisible(is_model_plane) + self.offset_spin.setEnabled(not is_model_plane) + + def get_selected_model_plane(self) -> tuple | None: + """Get the selected model plane object. + + Returns: + Tuple of (type, object, [face_index]) or None + """ + if self.plane_type_combo.currentText() != "Model Plane": + return None + + current_text = self.model_plane_combo.currentText() + if current_text == "No planes available": + return None + + return self.plane_objects.get(current_text) + + def get_parameters(self) -> dict: + """Get all parameters from the dialog.""" + params = { + "plane_type": self.plane_type_combo.currentText(), + "plane": self.plane_combo.currentText(), + "offset": self.offset_spin.value(), + "diameter": self.diameter_spin.value(), + "depth": self.depth_spin.value(), + "hole_count": self.hole_count_spin.value(), + "clearance_preferred": self.clearance_preferred_spin.value(), + "clearance_min": self.clearance_min_spin.value(), + "model_plane": self.get_selected_model_plane(), + } + return params + + def set_status(self, message: str, is_error: bool = False): + """Update status message.""" + if is_error: + self.status_label.setStyleSheet("color: red;") + else: + self.status_label.setStyleSheet("color: green;") + self.status_label.setText(message) + + def set_progress(self, value: int, maximum: int = 100): + """Update progress bar.""" + if not self.progress_bar.isVisible(): + self.progress_bar.setVisible(True) + self.progress_bar.setMaximum(maximum) + self.progress_bar.setValue(value) + QtGui.QApplication.processEvents() + + +class SmartCutter: + """Handles cutting objects and placing magnet holes with collision detection.""" + + def __init__(self, obj: Part.Feature, params: dict): + """Initialize the cutter. + + Args: + obj: FreeCAD object to cut + params: Dictionary of parameters from dialog + """ + self.obj = obj + self.params = params + self.shape = obj.Shape + # Detect existing holes from previous cuts + self.existing_holes = self._detect_existing_holes() + + def _detect_existing_holes(self) -> list[dict]: + """Detect existing magnet holes in the source object. + + Finds cylindrical faces that appear to be magnet holes based on + their radius matching common magnet sizes (or the current diameter). + + Returns: + List of dicts with hole info: center, axis, radius, depth + """ + holes = [] + target_radius = self.params.get("diameter", 3.0) / 2 + + # Group cylindrical faces by their axis and approximate center + # (a single hole creates one cylindrical face) + for face in self.shape.Faces: + if face.Surface.__class__.__name__ != "Cylinder": + continue + + radius = face.Surface.Radius + + # Only consider holes with radius close to target (within 50% tolerance) + # or small holes that are likely magnets (radius < 10mm) + if radius > 10 and abs(radius - target_radius) > target_radius * 0.5: + continue + + # Get the cylinder axis and a point on the axis + axis = face.Surface.Axis + center = face.Surface.Center + + # Get the face's bounding box to estimate hole depth + bbox = face.BoundBox + # The "depth" along the axis + depth = max(bbox.XLength, bbox.YLength, bbox.ZLength) + + holes.append( + { + "center": App.Vector(center), + "axis": App.Vector(axis), + "radius": radius, + "depth": depth, + "face_center": face.CenterOfMass, + } + ) + + App.Console.PrintMessage( + f"Detected {len(holes)} existing holes in source object\n" + ) + return holes + + def _project_existing_holes_to_cut_plane( + self, cut_normal: App.Vector, cut_point: App.Vector + ) -> list[App.Vector]: + """Project existing hole positions onto the new cut plane. + + For each existing hole, finds where its axis intersects the cut plane. + Only includes holes whose axis is roughly perpendicular to the cut plane + (i.e., holes that would connect through the cut). + + Args: + cut_normal: Normal vector of the cut plane + cut_point: A point on the cut plane + + Returns: + List of positions on the cut plane where existing holes should appear + """ + projected_positions = [] + + for hole in self.existing_holes: + hole_axis = hole["axis"] + hole_center = hole["center"] + + # Check if hole axis is roughly parallel to cut normal + # (meaning the hole goes "through" perpendicular to the cut) + dot = abs(hole_axis.dot(cut_normal)) + if dot < 0.7: # Not aligned enough + continue + + # Project the hole center onto the cut plane by finding where the + # hole axis line intersects the plane. Uses parametric line-plane + # intersection formula. + denominator = hole_axis.dot(cut_normal) + if abs(denominator) < 0.001: + continue # Parallel to plane, no intersection + + t = (cut_point - hole_center).dot(cut_normal) / denominator + intersection = hole_center + hole_axis * t + + projected_positions.append(intersection) + + App.Console.PrintMessage( + f"Projected {len(projected_positions)} existing holes to cut plane\n" + ) + return projected_positions + + def get_cut_plane_normal_and_point(self) -> tuple[App.Vector, App.Vector]: + """Get plane normal vector and point based on selected plane. + + Returns: + Tuple of (normal_vector, point_on_plane) + """ + plane_type = self.params.get("plane_type", "Preset Plane") + + # Handle model planes + if plane_type == "Model Plane": + model_plane = self.params.get("model_plane") + if not model_plane: + raise HolePlacementError("No model plane selected") + + return self._extract_plane_from_model(model_plane) + + # Handle preset planes + plane = self.params["plane"] + offset = self.params["offset"] + + if plane == "XY": + normal = App.Vector(0, 0, 1) + point = App.Vector(0, 0, offset) + elif plane == "XZ": + normal = App.Vector(0, 1, 0) + point = App.Vector(0, offset, 0) + elif plane == "YZ": + normal = App.Vector(1, 0, 0) + point = App.Vector(offset, 0, 0) + else: + # Default to XY + normal = App.Vector(0, 0, 1) + point = App.Vector(0, 0, offset) + + return normal, point + + def _extract_plane_from_model( + self, model_plane: tuple + ) -> tuple[App.Vector, App.Vector]: + """Extract normal and point from a FreeCAD plane object or face. + + Args: + model_plane: Tuple of (type, object, [face_index]) + + Returns: + Tuple of (normal_vector, point_on_plane) + """ + plane_type = model_plane[0] + + if plane_type == "plane": + # Datum plane object + plane_obj = model_plane[1] + + # Get the placement of the plane + placement = plane_obj.Placement + normal = placement.Rotation.multVec(App.Vector(0, 0, 1)) + point = placement.Base + + return normal, point + + elif plane_type == "face": + # Face of an object + obj = model_plane[1] + face_idx = model_plane[2] + face = obj.Shape.Faces[face_idx] + + # Get normal at the center of the face + u_mid = (face.ParameterRange[0] + face.ParameterRange[1]) / 2 + v_mid = (face.ParameterRange[2] + face.ParameterRange[3]) / 2 + normal = face.normalAt(u_mid, v_mid) + point = face.CenterOfMass + + return normal, point + else: + raise HolePlacementError(f"Unknown plane type: {plane_type}") + + def cut_object(self) -> tuple[Part.Shape, Part.Shape]: + """Cut the object along the specified plane. + + Works with arbitrary plane orientations by creating a large half-space + (box) that is properly rotated to align with the cutting plane. + + Returns: + Tuple of (bottom_part, top_part) where: + - bottom_part is the portion in the negative normal direction + - top_part is the portion in the positive normal direction + """ + normal, point = self.get_cut_plane_normal_and_point() + + # Create a large cutting box + bbox = self.shape.BoundBox + size = max(bbox.XLength, bbox.YLength, bbox.ZLength) * 3 + + # Create a box centered in XY at origin, extending from Z=0 to Z=size + # This box will represent the half-space "above" the cutting plane + half = size / 2 + box = Part.makeBox(size, size, size, App.Vector(-half, -half, 0)) + + # Rotate the box so its bottom face (originally Z=0) aligns with the plane + # We need a rotation that transforms the Z-axis to the plane normal + z_axis = App.Vector(0, 0, 1) + rotation = App.Rotation(z_axis, normal) + + # Apply the rotation using a transformation matrix + box = box.transformed(App.Matrix(rotation.toMatrix())) + + # Translate the box so the rotated Z=0 plane passes through the cut point + box.translate(point) + + # Perform cuts + # "bottom" = original minus the half-space above the plane + # "top" = original intersected with the half-space above the plane + try: + bottom_part = self.shape.cut(box) + top_part = self.shape.common(box) + return bottom_part, top_part + except Exception as e: + raise HolePlacementError(f"Failed to cut object: {e!s}") from e + + def get_cut_face_center( + self, part: Part.Shape, normal: App.Vector + ) -> App.Vector | None: + """Find the center of the cut face on a part. + + Args: + part: The part shape + normal: Normal vector of the cut plane + + Returns: + Center point of cut face or None if not found + """ + # Get the cut plane point to filter candidates + _, cut_point = self.get_cut_plane_normal_and_point() + + best_face = None + best_dist = float("inf") + + for face in part.Faces: + # Check if face is roughly parallel to cut plane + face_normal = face.normalAt(0, 0) + dot = abs(face_normal.dot(normal)) + if dot > 0.99: # Nearly parallel + # Check how close this face is to the cut plane + face_center = face.CenterOfMass + # Project face center onto plane normal and measure distance to cut point + dist_along_normal = abs((face_center - cut_point).dot(normal)) + if dist_along_normal < best_dist: + best_dist = dist_along_normal + best_face = face + + if best_face is not None: + return best_face.CenterOfMass + return None + + def is_hole_safe( + self, + center: App.Vector, + direction: App.Vector, + part: Part.Shape, + clearance: float | None = None, + ) -> bool: + """Check if a hole at this position would penetrate the outer surface. + + The safety check ensures that a hole with the specified clearance + around it won't break through the outer walls of the part. + + Args: + center: Center point of hole on cut surface + direction: Direction of hole (into the part) + part: Part shape to check against + clearance: Optional clearance to use for safety check. If not provided, + uses the minimum clearance from params. + + Returns: + True if hole is safe, False if it would penetrate + """ + diameter = self.params["diameter"] + depth = self.params["depth"] + if clearance is None: + clearance = self.params["clearance_min"] + + # Normalize direction + dir_normalized = App.Vector(direction).normalize() + + # Create a test cylinder that represents the hole + clearance margin + # Start the test cylinder slightly INSIDE the part to avoid the cut face + # boundary issue (the test should check if the hole fits within the + # solid material, not including the cut face surface itself) + radius_check = (diameter / 2) + clearance + start_offset = 0.5 # Start slightly inside the part + + # Position the test cylinder to start inside the part + start_pos = center + (dir_normalized * start_offset) + test_length = depth - start_offset # Reduce length accordingly + + # Only do the check if we have enough depth + if test_length <= 0: + return True # Hole is very shallow, assume safe + + # Create test cylinder + test_cylinder = Part.makeCylinder( + radius_check, test_length, start_pos, dir_normalized + ) + + # Check if cylinder is fully contained within the part + try: + intersection = part.common(test_cylinder) + + # If intersection volume is significantly less than cylinder volume, + # the hole would break through the outer surface + cylinder_vol = test_cylinder.Volume + intersection_vol = intersection.Volume + + # Allow 5% tolerance for floating point errors and minor surface irregularities + if intersection_vol < cylinder_vol * 0.95: + return False + + return True + except Exception: + # If boolean operation fails, consider it unsafe + return False + + def generate_hole_positions( + self, cut_face_center: App.Vector, cut_face: Part.Face + ) -> tuple[list[App.Vector], Part.Wire, float, list[float]]: + """Generate hole positions evenly distributed along the perimeter of the cut face. + + Instead of a grid pattern, this distributes N holes evenly along the + outer edge(s) of the cut face. This works better for magnet holes + that need to align when parts are joined. + + Uses the preferred clearance for initial hole placement. If holes fail + safety checks, the repositioning logic will try clearances down to minimum. + + Args: + cut_face_center: Center of the cut face + cut_face: The cut face geometry + + Returns: + Tuple of: + - List of hole center positions + - The outer wire (perimeter) of the cut face + - Total perimeter length + - List of original perimeter parameters for each position + """ + hole_count = self.params["hole_count"] + # Use preferred clearance for initial placement + clearance = self.params["clearance_preferred"] + diameter = self.params["diameter"] + + # Get the outer wire (perimeter) of the cut face + # For faces with holes (like ring shapes), there may be multiple wires + # The outer wire is typically the longest one + wires = cut_face.Wires + if not wires: + App.Console.PrintError("Cut face has no wires (edges)\n") + return [], None, 0, [] + + # Find the outer wire (longest perimeter) + outer_wire = max(wires, key=lambda w: w.Length) + perimeter_length = outer_wire.Length + + App.Console.PrintMessage( + f"Cut face perimeter length: {perimeter_length:.2f} mm\n" + ) + + # Calculate the inset distance from the edge + # Holes should be placed inward from the edge by clearance + radius + inset = clearance + (diameter / 2) + + # Get the normal vector for the cut plane + normal, _ = self.get_cut_plane_normal_and_point() + normal = App.Vector(normal).normalize() + + # Distribute holes evenly along the perimeter + # Calculate spacing between holes + if hole_count < 1: + return [], outer_wire, perimeter_length, [] + + # For N holes distributed around a closed perimeter, the spacing between + # adjacent holes (including wrap-around from last to first) equals + # perimeter_length divided by hole_count. This ensures equal distance + # between all holes, including first and last. + spacing = perimeter_length / hole_count + + App.Console.PrintMessage( + f"Placing {hole_count} holes with {spacing:.2f} mm spacing\n" + ) + + positions = [] + original_params = [] + + for i in range(hole_count): + # Parameter along the wire (0 to perimeter_length) + # Place holes evenly spaced with a small offset to avoid starting + # exactly at position 0 (which is often a corner/vertex where + # determining the inward direction can be problematic) + # Offset by half the spacing so holes are centered in their segments + param = (i * spacing) + (spacing / 2) + # Wrap around if we exceed perimeter length + if param >= perimeter_length: + param = param - perimeter_length + + # Get the point on the edge at this parameter + # We need to walk along the wire's edges + edge_point = self._get_point_at_length(outer_wire, param) + if edge_point is None: + continue + + # Now we need to move this point INWARD from the edge + # toward the center of the face (or the solid material for ring shapes) + inset_point = self._get_inset_point(edge_point, cut_face, normal, inset) + + if inset_point: + positions.append(inset_point) + original_params.append(param) + + App.Console.PrintMessage(f"Generated {len(positions)} hole positions\n") + return positions, outer_wire, perimeter_length, original_params + + def _get_point_at_length(self, wire: Part.Wire, length: float) -> App.Vector | None: + """Get a point on the wire at a specific length along it. + + Args: + wire: The wire to traverse + length: Distance along the wire + + Returns: + Point at that distance, or None if not found + """ + cumulative_length = 0.0 + + for edge in wire.Edges: + edge_length = edge.Length + + if cumulative_length + edge_length >= length: + # The point is on this edge + # Calculate how far along this edge + remaining = length - cumulative_length + # Parameter is normalized (0 to 1) along the edge + param = remaining / edge_length if edge_length > 0 else 0 + + # Get the point using edge parameter space + # Edge parameters go from edge.FirstParameter to edge.LastParameter + first_param = edge.FirstParameter + last_param = edge.LastParameter + edge_param = first_param + param * (last_param - first_param) + + try: + point = edge.valueAt(edge_param) + return App.Vector(point) + except Exception: + return None + + cumulative_length += edge_length + + # If we get here, length exceeded wire length (shouldn't happen with valid input) + return None + + def _get_inset_point( + self, + edge_point: App.Vector, + cut_face: Part.Face, + normal: App.Vector, + inset: float, + ) -> App.Vector | None: + """Get a point that is inset from the edge toward the face interior. + + For simple shapes, this moves toward the face center. + For ring shapes, it moves toward the solid material. + + Args: + edge_point: Point on the edge + cut_face: The cut face + normal: Normal vector of the cut plane + inset: Distance to move inward + + Returns: + Inset point on the face, or None if invalid + """ + # Get the center of mass of the face + face_center = cut_face.CenterOfMass + + # Direction from edge point toward center (projected onto the plane) + to_center = face_center - edge_point + + # Remove any component along the normal (project onto plane) + to_center = to_center - normal * (to_center.dot(normal)) + + if to_center.Length < 0.001: + # Edge point is at center, can't determine direction + return None + + # Normalize the direction + to_center_normalized = App.Vector(to_center).normalize() + + # Move inward by the inset distance + inset_point = edge_point + (to_center_normalized * inset) + + # Verify the inset point is actually on the face + # (important for ring shapes where center of mass may be in the hole) + try: + dist_info = cut_face.distToShape(Part.Vertex(inset_point)) + dist = dist_info[0] + + if dist < 0.5: + # Point is on or very close to the face + closest_on_face = dist_info[1][0][0] + return App.Vector(closest_on_face) + else: + # Point is not on the face - for ring shapes, we may need + # to adjust. Try projecting directly onto the face. + dist_info = cut_face.distToShape(Part.Vertex(inset_point)) + return App.Vector(dist_info[1][0][0]) + except Exception as e: + App.Console.PrintWarning(f"Failed to validate inset point: {e}\n") + return None + + def _find_alternative_position( + self, + original_pos: App.Vector, + bottom_part: Part.Shape, + top_part: Part.Shape, + bottom_cut_face: Part.Face, + top_cut_face: Part.Face, + outer_wire: Part.Wire, + perimeter_length: float, + original_param: float, + normal: App.Vector, + ) -> App.Vector | None: + """Try to find an alternative hole position when the original fails safety check. + + This method checks BOTH parts to ensure the repositioned hole works for both + the bottom and top pieces. + + Strategy: + 1. Try reducing clearance from preferred toward minimum (at same position) + 2. Try moving further inward from the edge (increased inset with preferred clearance) + 3. Try positions along the perimeter in both directions + + Args: + original_pos: The original position that failed + bottom_part: The bottom part shape + top_part: The top part shape + bottom_cut_face: The bottom cut face + top_cut_face: The top cut face + outer_wire: The outer wire (perimeter) + perimeter_length: Total perimeter length + original_param: Original parameter along the perimeter + normal: Normal vector of the cut plane + + Returns: + Alternative position if found, None otherwise + """ + diameter = self.params["diameter"] + clearance_preferred = self.params["clearance_preferred"] + clearance_min = self.params["clearance_min"] + + # Build a list of clearances to try, from preferred down to minimum + # We try: preferred, 75% toward min, 50% toward min, 25% toward min, min + clearance_steps = [] + if clearance_preferred > clearance_min: + step_size = (clearance_preferred - clearance_min) / 4 + for i in range(5): # 0=preferred, 4=min + clearance_steps.append(clearance_preferred - (i * step_size)) + else: + clearance_steps = [clearance_min] + + def is_safe_for_both(pos: App.Vector, check_clearance: float) -> bool: + """Check if position is safe for both bottom and top parts at given clearance.""" + # Check bottom part (holes go in -normal direction) + if not self.is_hole_safe(pos, -normal, bottom_part, check_clearance): + return False + # Check top part (holes go in +normal direction) + if not self.is_hole_safe(pos, normal, top_part, check_clearance): + return False + return True + + # Strategy 1: Try reducing clearance at the SAME position + # This keeps holes in their ideal locations when possible + if original_param is not None: + edge_point = self._get_point_at_length(outer_wire, original_param) + if edge_point: + for try_clearance in clearance_steps[ + 1: + ]: # Skip preferred, we already tried it + inset = try_clearance + (diameter / 2) + inset_pos = self._get_inset_point( + edge_point, bottom_cut_face, normal, inset + ) + if inset_pos and is_safe_for_both(inset_pos, try_clearance): + return inset_pos + + # Strategy 2: Try moving further inward from the edge (multiplied inset) + # Using each clearance level + if original_param is not None: + edge_point = self._get_point_at_length(outer_wire, original_param) + if edge_point: + for try_clearance in clearance_steps: + base_inset = try_clearance + (diameter / 2) + for multiplier in [1.5, 2.0, 2.5, 3.0]: + increased_inset = base_inset * multiplier + inset_pos = self._get_inset_point( + edge_point, bottom_cut_face, normal, increased_inset + ) + if inset_pos and is_safe_for_both(inset_pos, try_clearance): + return inset_pos + + # Strategy 3: Try positions along the perimeter in both directions + # Search up to 20% of segment length in each direction + if original_param is not None and perimeter_length > 0: + segment_length = perimeter_length / self.params["hole_count"] + + # Try offsets in both directions: +5%, +10%, +15%, +20%, -5%, -10%, etc. + offsets = [] + for pct in [0.05, 0.10, 0.15, 0.20]: + offsets.append(segment_length * pct) + offsets.append(-segment_length * pct) + + for offset in offsets: + new_param = (original_param + offset) % perimeter_length + edge_point = self._get_point_at_length(outer_wire, new_param) + if edge_point is None: + continue + + # Try different clearance levels and inset distances + for try_clearance in clearance_steps: + base_inset = try_clearance + (diameter / 2) + for multiplier in [1.0, 1.5, 2.0, 2.5]: + inset = base_inset * multiplier + inset_pos = self._get_inset_point( + edge_point, bottom_cut_face, normal, inset + ) + if inset_pos and is_safe_for_both(inset_pos, try_clearance): + return inset_pos + + return None + + def _check_hole_overlap( + self, positions: list[App.Vector], new_pos: App.Vector + ) -> bool: + """Check if a new hole position would overlap with existing holes. + + Holes must have at least one hole diameter of space between them. + + Args: + positions: List of already accepted hole positions + new_pos: The new position to check + + Returns: + True if position is valid (no overlap), False if it would overlap + """ + diameter = self.params["diameter"] + # Minimum distance = 2 * diameter (one hole width between holes) + min_distance = diameter * 2 + + for existing_pos in positions: + # Calculate distance in XY plane (on the cut face) + dist = (new_pos - existing_pos).Length + if dist < min_distance: + return False + + return True + + def execute(self, progress_callback=None): + """Execute the complete cutting and hole placement operation. + + This method: + 1. Cuts the object along the specified plane + 2. Creates PartDesign::Body objects for each half + 3. Validates hole positions against BOTH parts (not just one) + 4. Checks for minimum spacing between holes (2x diameter) + 5. Creates PartDesign::Hole features in both parts + + Each major step is wrapped in a FreeCAD transaction, allowing + users to undo individual steps via Edit → Undo in the GUI. + + Args: + progress_callback: Optional callback function for progress updates + + Returns: + Tuple of (bottom_body, top_body) - PartDesign::Body objects + """ + doc = App.ActiveDocument + + # Count cylindrical faces (holes) in a shape + def count_cylindrical_faces(shape): + count = 0 + for face in shape.Faces: + if face.Surface.__class__.__name__ == "Cylinder": + count += 1 + return count + + # Log detailed information about the source object + App.Console.PrintMessage( + f"\n{'='*60}\n" + f"Starting cut operation on: {self.obj.Label} ({self.obj.Name})\n" + f"Object type: {self.obj.TypeId}\n" + f"Shape faces: {len(self.shape.Faces)}, volume: {self.shape.Volume:.2f}mm³\n" + f"Cylindrical faces (existing holes): {count_cylindrical_faces(self.shape)}\n" + ) + + # If cutting a PartDesign::Body, log its structure + if hasattr(self.obj, "Group"): + App.Console.PrintMessage( + f"Body Group: {[f'{o.Name} ({o.TypeId})' for o in self.obj.Group]}\n" + ) + if hasattr(self.obj, "BaseFeature") and self.obj.BaseFeature: + App.Console.PrintMessage(f"Body BaseFeature: {self.obj.BaseFeature.Name}\n") + if hasattr(self.obj, "Tip") and self.obj.Tip: + App.Console.PrintMessage( + f"Body Tip: {self.obj.Tip.Name} ({self.obj.Tip.TypeId})\n" + ) + App.Console.PrintMessage(f"{'='*60}\n\n") + + if progress_callback: + progress_callback(10, "Cutting object...") + + # Cut the object (returns Part.Shape objects) + # Note: This is a pure geometry operation, no document changes yet + bottom_shape, top_shape = self.cut_object() + + # Log cut results + App.Console.PrintMessage( + f"Cut results:\n" + f" Bottom shape: {len(bottom_shape.Faces)} faces, " + f"volume={bottom_shape.Volume:.2f}mm³, " + f"cylindrical faces={count_cylindrical_faces(bottom_shape)}\n" + f" Top shape: {len(top_shape.Faces)} faces, " + f"volume={top_shape.Volume:.2f}mm³, " + f"cylindrical faces={count_cylindrical_faces(top_shape)}\n" + ) + + if progress_callback: + progress_callback(25, "Finding cut faces...") + + # Get cut plane normal + normal, _ = self.get_cut_plane_normal_and_point() + + # Find cut faces (on shapes, before converting to bodies) + bottom_face_center = self.get_cut_face_center(bottom_shape, -normal) + top_face_center = self.get_cut_face_center(top_shape, normal) + + if not bottom_face_center or not top_face_center: + raise HolePlacementError("Could not find cut faces") + + if progress_callback: + progress_callback(35, "Generating hole positions...") + + # Find the actual cut face from bottom part + bottom_cut_face = None + for face in bottom_shape.Faces: + if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1: + bottom_cut_face = face + break + + if not bottom_cut_face: + raise HolePlacementError("Could not find bottom cut face") + + # Find the actual cut face from top part (for repositioning on top part) + top_cut_face = None + for face in top_shape.Faces: + if face.CenterOfMass.distanceToPoint(top_face_center) < 0.1: + top_cut_face = face + break + + if not top_cut_face: + raise HolePlacementError("Could not find top cut face") + + # Get the cut plane point for projecting existing holes + _, cut_point = self.get_cut_plane_normal_and_point() + + # Project existing holes from previous cuts onto the new cut plane + # These holes MUST be preserved to maintain magnet alignment + existing_hole_positions = self._project_existing_holes_to_cut_plane( + normal, cut_point + ) + + # Generate NEW hole positions for this cut + new_positions, outer_wire, perimeter_length, original_params = ( + self.generate_hole_positions(bottom_face_center, bottom_cut_face) + ) + + App.Console.PrintMessage( + f"Hole positions: {len(existing_hole_positions)} existing + " + f"{len(new_positions)} new\n" + ) + + # Combine existing and new positions + # Existing holes are mandatory - they maintain magnet alignment from previous cuts + # New holes are added for this cut's magnet connections + initial_positions = existing_hole_positions + new_positions + + if not initial_positions: + raise HolePlacementError("No valid hole positions found") + + if progress_callback: + progress_callback(45, "Validating hole positions on both parts...") + + # Validate each position against BOTH parts and check for overlap + # This ensures holes are placed identically in both parts + validated_positions = [] + holes_repositioned = 0 + holes_skipped = 0 + num_existing = len(existing_hole_positions) + + # Use preferred clearance for initial validation + clearance_preferred = self.params["clearance_preferred"] + + for idx, pos in enumerate(initial_positions): + is_existing_hole = idx < num_existing + + # For new holes, get the original parameter for repositioning + if not is_existing_hole: + new_idx = idx - num_existing + original_param = ( + original_params[new_idx] if new_idx < len(original_params) else None + ) + else: + original_param = None + + # Check if position is safe for both parts using preferred clearance + bottom_safe = self.is_hole_safe( + pos, -normal, bottom_shape, clearance_preferred + ) + top_safe = self.is_hole_safe(pos, normal, top_shape, clearance_preferred) + + final_pos = None + + if bottom_safe and top_safe: + # Position is good for both parts + final_pos = pos + elif is_existing_hole: + # Existing holes should be preserved IF they pass minimum clearance + # If they fail even minimum clearance, they would break the wall + clearance_min = self.params["clearance_min"] + bottom_safe_min = self.is_hole_safe( + pos, -normal, bottom_shape, clearance_min + ) + top_safe_min = self.is_hole_safe(pos, normal, top_shape, clearance_min) + if bottom_safe_min and top_safe_min: + final_pos = pos + App.Console.PrintWarning( + f"Existing hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) " + f"uses minimum clearance\n" + ) + else: + # Existing hole would break through wall - skip it + # This happens when cutting through a face that had holes, + # and some holes are now outside the new cut face boundary + App.Console.PrintWarning( + f"Skipping existing hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) " + f"- would break through outer wall (outside cut face boundary)\n" + ) + holes_skipped += 1 + continue + else: + # Try to find an alternative position that works for both + alternative = self._find_alternative_position( + pos, + bottom_shape, + top_shape, + bottom_cut_face, + top_cut_face, + outer_wire, + perimeter_length, + original_param, + normal, + ) + if alternative: + final_pos = alternative + holes_repositioned += 1 + App.Console.PrintMessage( + f"Repositioned new hole {idx+1} from ({pos.x:.2f}, {pos.y:.2f}) " + f"to ({alternative.x:.2f}, {alternative.y:.2f})\n" + ) + + if final_pos: + # Check for overlap with already validated positions + # But existing holes always get added (they're mandatory) + if is_existing_hole or self._check_hole_overlap( + validated_positions, final_pos + ): + validated_positions.append(final_pos) + else: + holes_skipped += 1 + App.Console.PrintWarning( + f"Skipping new hole at ({final_pos.x:.2f}, {final_pos.y:.2f}) " + f"- too close to another hole (need {self.params['diameter'] * 2:.1f}mm spacing)\n" + ) + else: + holes_skipped += 1 + App.Console.PrintWarning( + f"Skipping hole {idx+1} at ({pos.x:.2f}, {pos.y:.2f}) " + f"- could not find safe position for both parts\n" + ) + + if not validated_positions: + raise HolePlacementError("No valid hole positions found after validation") + + App.Console.PrintMessage( + f"Validated {len(validated_positions)} hole positions " + f"({holes_repositioned} repositioned, {holes_skipped} skipped)\n" + ) + + if progress_callback: + progress_callback(55, "Creating PartDesign bodies...") + + # Transaction 1: Create bottom body from cut shape + doc.openTransaction("Create Bottom Body") + try: + bottom_body = self._create_body_from_shape( + bottom_shape, f"{self.obj.Label}_Bottom" + ) + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + # Transaction 2: Create top body from cut shape + doc.openTransaction("Create Top Body") + try: + top_body = self._create_body_from_shape(top_shape, f"{self.obj.Label}_Top") + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + if progress_callback: + progress_callback(65, "Finding cut faces on bodies...") + + # Find cut face names on the new bodies + # Note: Face normals point OUTWARD from each solid piece: + # - Bottom piece's cut face normal points toward top (same as plane normal) + # - Top piece's cut face normal points toward bottom (opposite to plane normal) + bottom_face_name = self._find_cut_face_name(bottom_body, normal) + top_face_name = self._find_cut_face_name(top_body, -normal) + + App.Console.PrintMessage( + f"Cut faces: bottom={bottom_face_name}, top={top_face_name}\n" + ) + + if progress_callback: + progress_callback(75, "Creating hole sketch for bottom part...") + + # Transaction 3: Create hole sketch for bottom body + doc.openTransaction("Create Bottom Hole Sketch") + try: + bottom_sketch = self._create_hole_sketch( + bottom_body, bottom_face_name, validated_positions + ) + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + if progress_callback: + progress_callback(82, "Creating hole sketch for top part...") + + # Transaction 4: Create hole sketch for top body + doc.openTransaction("Create Top Hole Sketch") + try: + top_sketch = self._create_hole_sketch( + top_body, top_face_name, validated_positions + ) + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + if progress_callback: + progress_callback( + 88, f"Creating {len(validated_positions)} holes in bottom part..." + ) + + # Transaction 5: Create hole feature in bottom body + doc.openTransaction("Create Bottom Magnet Holes") + try: + self._create_hole_feature( + bottom_body, + bottom_sketch, + self.params["diameter"], + self.params["depth"], + ) + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + if progress_callback: + progress_callback(95, "Creating holes in top part...") + + # Transaction 6: Create hole feature in top body + doc.openTransaction("Create Top Magnet Holes") + try: + self._create_hole_feature( + top_body, top_sketch, self.params["diameter"], self.params["depth"] + ) + doc.commitTransaction() + except Exception: + doc.abortTransaction() + raise + + if progress_callback: + progress_callback(96, "Separating cut parts...") + + # Transaction 7: Move top body away from bottom body (100mm separation) + doc.openTransaction("Separate Cut Parts") + try: + # Move the top body along the cut plane normal direction + # This creates a 100mm gap between the cut faces + separation_distance = 100.0 # mm + offset_vector = App.Vector( + normal.x * separation_distance, + normal.y * separation_distance, + normal.z * separation_distance, + ) + + # Get current placement and add offset + current_placement = top_body.Placement + new_base = current_placement.Base + offset_vector + top_body.Placement = App.Placement( + new_base, current_placement.Rotation, App.Vector(0, 0, 0) + ) + + doc.commitTransaction() + App.Console.PrintMessage( + f"Separated parts by {separation_distance}mm along cut normal\n" + ) + except Exception as e: + doc.abortTransaction() + App.Console.PrintWarning(f"Could not separate parts: {e}\n") + + if progress_callback: + progress_callback(98, "Hiding original objects...") + + # Transaction 8: Hide original object and cutting plane + doc.openTransaction("Hide Original Objects") + try: + # Hide the original object + if hasattr(self.obj, "ViewObject") and self.obj.ViewObject: + self.obj.ViewObject.Visibility = False + + # Hide the cutting plane if it's a model plane + if self.params.get("plane_type") == "Model Plane": + model_plane = self.params.get("model_plane") + if model_plane and len(model_plane) >= 2: + plane_obj = model_plane[1] + if hasattr(plane_obj, "ViewObject") and plane_obj.ViewObject: + plane_obj.ViewObject.Visibility = False + + doc.commitTransaction() + except Exception: + # Don't fail the whole operation if hiding fails + doc.abortTransaction() + App.Console.PrintWarning( + "Could not hide original objects (GUI may not be available)\n" + ) + + if progress_callback: + progress_callback(100, "Complete!") + + return bottom_body, top_body + + def _create_body_from_shape(self, shape: Part.Shape, name: str): + """Create a PartDesign::Body containing the given shape. + + Uses Body.BaseFeature property to wrap an existing shape, allowing + PartDesign features (like Hole) to be added to imported/boolean geometry. + + Args: + shape: The Part.Shape to wrap + name: Name for the new body + + Returns: + The created PartDesign::Body object + """ + doc = App.ActiveDocument + + # Log diagnostic information about the input shape + App.Console.PrintMessage( + f"Creating body '{name}' from shape with {len(shape.Faces)} faces, " + f"volume={shape.Volume:.2f}mm³\n" + ) + + # First create a Part::Feature to hold the shape + # This is needed because BaseFeature references a document object, not a raw shape + base_feature_name = f"{name}_Base" + feature = doc.addObject("Part::Feature", base_feature_name) + feature.Shape = shape + + # Create PartDesign::Body + body = doc.addObject("PartDesign::Body", name) + + # Set the BaseFeature property to reference the Part::Feature + # Note: This is a property, not created via newObject() + body.BaseFeature = feature + + # Hide the intermediate Part::Feature (it's now part of the body) + if hasattr(feature, "ViewObject") and feature.ViewObject: + feature.ViewObject.Visibility = False + + doc.recompute() + + # Log the created body structure + App.Console.PrintMessage( + f"Created body '{name}': BaseFeature={body.BaseFeature.Name if body.BaseFeature else 'None'}, " + f"Group={[obj.Name for obj in body.Group]}\n" + ) + + return body + + def _get_internal_base_feature(self, body): + """Get the internal PartDesign::FeatureBase from a body. + + When you set body.BaseFeature = some_part_feature, FreeCAD creates + an internal PartDesign::FeatureBase in body.Group. This internal + feature is what sketches should be attached to, not the external + Part::Feature. + + Args: + body: The PartDesign::Body to search + + Returns: + The PartDesign::FeatureBase object + + Raises: + HolePlacementError: If no FeatureBase is found + """ + for obj in body.Group: + if obj.TypeId == "PartDesign::FeatureBase": + return obj + + raise HolePlacementError( + f"Body {body.Label} has no PartDesign::FeatureBase in Group" + ) + + def _find_cut_face_name(self, body, normal: App.Vector) -> str: + """Find the name of the cut face on a PartDesign::Body's internal FeatureBase. + + For planar cuts on flat objects, this searches for faces with matching normals. + For curved objects (like vases), it finds the largest face whose center + lies closest to the cut plane. + + Note: We use the internal PartDesign::FeatureBase (from body.Group) because: + 1. It's the stable internal representation of the imported shape + 2. Sketches must be attached to PartDesign features, not Part::Feature + 3. body.Tip might be a failed Hole feature from a previous run + 4. body.BaseFeature is the external Part::Feature, not suitable for sketch attachment + + Args: + body: The PartDesign::Body to search + normal: Expected normal direction of the cut face + + Returns: + Face name string like "Face1", "Face2", etc. + + Raises: + HolePlacementError: If no matching face is found + """ + # Get the internal PartDesign::FeatureBase - this is what sketches attach to + base_feature = self._get_internal_base_feature(body) + + shape = base_feature.Shape + + # Normalize the target normal + target_normal = App.Vector(normal).normalize() + + # Get the cut plane point + _, cut_point = self.get_cut_plane_normal_and_point() + + # Find candidates: faces with matching normal AND close to cut plane + # This handles both: + # 1. Fresh cuts (single matching face) + # 2. Re-cuts of already-cut objects (multiple planar faces, need the NEW one) + candidates = [] + + for i, face in enumerate(shape.Faces): + try: + face_normal = face.normalAt(0.5, 0.5) + dot = face_normal.dot(target_normal) + + # Skip faces with wrong normal direction + if dot < 0.3: + continue + + # Calculate distance from face center to the cut plane + face_center = face.CenterOfMass + dist_to_plane = abs((face_center - cut_point).dot(target_normal)) + + candidates.append( + { + "index": i, + "dist": dist_to_plane, + "dot": dot, + "area": face.Area, + "surface_type": face.Surface.__class__.__name__, + } + ) + except Exception: + continue + + if not candidates: + raise HolePlacementError( + f"Could not find any face on body {body.Label} with normal " + f"matching the cut plane direction" + ) + + # Strategy 1: Look for planar faces with exact normal match AND close to cut plane + # This is the ideal case - a flat face created by the current cut + planar_matches = [ + c + for c in candidates + if c["surface_type"] == "Plane" and c["dot"] > 0.99 and c["dist"] < 5.0 + ] + + # Log all planar matches for debugging + if planar_matches: + App.Console.PrintMessage( + f"Found {len(planar_matches)} planar face candidates close to cut plane:\n" + ) + for m in planar_matches[:5]: # Show up to 5 + App.Console.PrintMessage( + f" Face{m['index']+1}: dist={m['dist']:.2f}mm, " + f"dot={m['dot']:.3f}, area={m['area']:.1f}mm²\n" + ) + + if planar_matches: + # Sort by distance to plane (closest first), then by area (largest first) + planar_matches.sort(key=lambda x: (x["dist"], -x["area"])) + best = planar_matches[0] + App.Console.PrintMessage( + f"Selected cut face (planar, exact match): Face{best['index']+1} " + f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, " + f"area={best['area']:.1f}mm²)\n" + ) + return f"Face{best['index'] + 1}" + + # Strategy 2: Look for any face with good normal match close to the cut plane + # This handles curved objects where cut face might not be perfectly planar + close_matches = [c for c in candidates if c["dist"] < 5.0 and c["dot"] > 0.5] + + if close_matches: + # Sort by dot product (best match first), then distance, then area + close_matches.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"])) + best = close_matches[0] + App.Console.PrintMessage( + f"Found cut face (close to plane): Face{best['index']+1} " + f"(dist={best['dist']:.2f}mm, dot={best['dot']:.3f}, " + f"area={best['area']:.1f}mm², type={best['surface_type']})\n" + ) + return f"Face{best['index'] + 1}" + + # Strategy 3: Fallback - best dot product match regardless of distance + # This might pick a face from a previous cut, but it's better than failing + candidates.sort(key=lambda x: (-x["dot"], x["dist"], -x["area"])) + best = candidates[0] + App.Console.PrintWarning( + f"Warning: No face close to cut plane found. Using best normal match: " + f"Face{best['index']+1} (dist={best['dist']:.2f}mm, dot={best['dot']:.3f})\n" + ) + return f"Face{best['index'] + 1}" + + def _world_to_sketch_coords(self, world_pos: App.Vector, sketch) -> App.Vector: + """Transform world coordinates to sketch-local 2D coordinates. + + Sketches use a local 2D coordinate system. This transforms a 3D world + position to the corresponding 2D position in the sketch plane. + + Args: + world_pos: Position in world (document) coordinates + sketch: The Sketcher::SketchObject with placement info + + Returns: + Position in sketch-local coordinates (Z should be ~0) + """ + # Get sketch placement (transforms sketch coords to world) + placement = sketch.Placement + + # Inverse transform: world to sketch local + inv_placement = placement.inverse() + local_pos = inv_placement.multVec(world_pos) + + # Return 2D (Z should be ~0 for points on the sketch plane) + return App.Vector(local_pos.x, local_pos.y, 0) + + def _create_hole_sketch( + self, body, cut_face_name: str, positions: list[App.Vector] + ): + """Create a sketch with points at hole center positions. + + The sketch is attached to the cut face and contains points that + will be used as hole centers for the PartDesign::Hole feature. + + Args: + body: The PartDesign::Body to add the sketch to + cut_face_name: Name of the face to attach the sketch to + positions: List of hole center positions in world coordinates + + Returns: + The created Sketcher::SketchObject + """ + # Create sketch attached to cut face on the internal PartDesign::FeatureBase + # Sketches must reference a PartDesign feature (not Part::Feature), not the body + sketch = body.newObject("Sketcher::SketchObject", "HoleCenters") + + # Get the internal PartDesign::FeatureBase (not body.BaseFeature which is Part::Feature) + # This is the stable internal representation that sketches can attach to + base_feature = self._get_internal_base_feature(body) + + # AttachmentSupport format: list of (feature, [face_names]) + # Note: In FreeCAD 1.0+, use AttachmentSupport instead of deprecated Support + sketch.AttachmentSupport = [(base_feature, cut_face_name)] + sketch.MapMode = "FlatFace" + + # Recompute to establish sketch placement + App.ActiveDocument.recompute() + + # Add point at each hole position + # Points need to be in sketch-local coordinates + for pos in positions: + local_pos = self._world_to_sketch_coords(pos, sketch) + sketch.addGeometry( + Part.Point(App.Vector(local_pos.x, local_pos.y, 0)), + False, # Not construction geometry + ) + + App.ActiveDocument.recompute() + return sketch + + def _create_hole_feature(self, body, sketch, diameter: float, depth: float): + """Create a PartDesign::Hole feature from a sketch with point geometry. + + The Hole feature creates cylindrical holes at each point in the sketch. + These holes are parametric and can be edited after creation. + + Args: + body: The PartDesign::Body containing the sketch + sketch: Sketch with points defining hole centers + diameter: Hole diameter in mm + depth: Hole depth in mm + + Returns: + The created PartDesign::Hole feature + """ + hole = body.newObject("PartDesign::Hole", "MagnetHoles") + hole.Profile = sketch + hole.Diameter = diameter + hole.Depth = depth + hole.DepthType = "Dimension" # Fixed depth (not "ThroughAll") + hole.Threaded = False + hole.HoleCutType = "None" # Simple hole (no countersink/counterbore) + + App.ActiveDocument.recompute() + + # Validate the hole feature was created successfully + if hasattr(hole, "isValid") and callable(hole.isValid): + is_valid = hole.isValid() + else: + # Check if the shape has non-zero volume as a proxy for validity + is_valid = hasattr(hole, "Shape") and hole.Shape.Volume > 0 + + App.Console.PrintMessage( + f"Created hole feature '{hole.Name}' on body '{body.Label}': " + f"valid={is_valid}, " + f"profile={sketch.Name}, " + f"diameter={diameter}mm, depth={depth}mm\n" + ) + + # Log body shape info after hole creation + if hasattr(body, "Shape"): + App.Console.PrintMessage( + f"Body '{body.Label}' after holes: " + f"{len(body.Shape.Faces)} faces, " + f"volume={body.Shape.Volume:.2f}mm³\n" + ) + + return hole + + def _create_holes_boolean( + self, part: Part.Shape, direction: App.Vector, positions: list[App.Vector] + ) -> Part.Shape: + """Create holes using boolean operations (fallback method). + + This is the original hole creation method using Part.makeCylinder + and boolean cut operations. Kept as fallback if PartDesign::Hole + fails for certain geometry types. + + Args: + part: Part shape to add holes to + direction: Direction of holes (pointing INTO the part) + positions: List of validated hole positions + + Returns: + Part with holes cut + """ + diameter = self.params["diameter"] + depth = self.params["depth"] + + # Normalize direction vector + dir_normalized = App.Vector(direction).normalize() + + result = part + holes_created = 0 + + for pos in positions: + # Create hole - start slightly OUTSIDE the part (offset back from cut face) + # so the boolean cut operation works correctly + offset = 0.1 + start_pos = pos - (dir_normalized * offset) + hole_length = depth + offset + + try: + hole = Part.makeCylinder( + diameter / 2, hole_length, start_pos, dir_normalized + ) + result = result.cut(hole) + holes_created += 1 + except Exception as e: + App.Console.PrintWarning( + f"Failed to create hole at ({pos.x:.2f}, {pos.y:.2f}): {e!s}\n" + ) + + App.Console.PrintMessage(f"Created {holes_created} holes\n") + return result + + +def _is_plane_object(obj) -> bool: + """Check if an object is a datum plane or has a planar face.""" + if hasattr(obj, "TypeId"): + if "Plane" in obj.TypeId: + return True + return False + + +def _get_object_type(obj) -> str: + """Get a human-readable type description for an object.""" + if hasattr(obj, "TypeId"): + type_id = obj.TypeId + if "Part::" in type_id: + return type_id.replace("Part::", "") + if "PartDesign::" in type_id: + return type_id.replace("PartDesign::", "") + if "Mesh::" in type_id: + return "Mesh" + return type_id + if hasattr(obj, "Shape"): + return "Shape" + return "" + + +def main(): + """Main macro entry point.""" + # Check for active document + if not App.ActiveDocument: + QtGui.QMessageBox.warning( + None, "No Document", "Please open or create a document first." + ) + return + + # Get current selection to use as defaults + selection = Gui.Selection.getSelection() + + # Build a map of BaseFeature -> Body for resolving intermediate objects + base_to_body = {} + for obj in App.ActiveDocument.Objects: + if hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body": + if hasattr(obj, "BaseFeature") and obj.BaseFeature: + base_to_body[obj.BaseFeature.Name] = obj + + # Determine default object and plane from selection + default_obj = None + selected_plane = None + + for sel_obj in selection: + if _is_plane_object(sel_obj): + selected_plane = sel_obj + elif hasattr(sel_obj, "Shape") and not default_obj: + # Check if this is actually a BaseFeature of a Body + # If so, use the Body instead + if sel_obj.Name in base_to_body: + default_obj = base_to_body[sel_obj.Name] + # Skip hidden objects and _Base suffixed objects + elif sel_obj.Name.endswith("_Base") or sel_obj.Label.endswith("_Base"): + # Try to find the corresponding body + body_name = sel_obj.Name.replace("_Base", "") + body = App.ActiveDocument.getObject(body_name) + if body and hasattr(body, "Shape"): + default_obj = body + elif hasattr(sel_obj, "ViewObject") and sel_obj.ViewObject: + if sel_obj.ViewObject.Visibility: + default_obj = sel_obj + else: + default_obj = sel_obj + + # Show dialog - user can select/change object in the dialog + dialog = CutObjectForMagnetsDialog() + + # Set the default object (from selection) if we found one + if default_obj: + dialog.set_selected_object(default_obj.Label) + + # If a plane was selected, set it as the default cut plane + if selected_plane: + dialog.set_default_plane(selected_plane.Label) + + if dialog.exec_() != QtGui.QDialog.Accepted: + return + + # Get the object selected in the dialog (user may have changed it) + obj = dialog.get_selected_object() + if obj is None: + QtGui.QMessageBox.warning( + None, "No Object Selected", "Please select an object to cut." + ) + return + + params = dialog.get_parameters() + + # Validate model plane selection + if params["plane_type"] == "Model Plane": + if not params["model_plane"]: + QtGui.QMessageBox.warning( + None, + "No Plane Selected", + "Please select a model plane or switch to preset plane mode.", + ) + return + if dialog.model_plane_combo.currentText() == "No planes available": + QtGui.QMessageBox.warning( + None, + "No Planes Available", + "No datum planes or planar faces found in the document.\n\n" + "Create a datum plane (Part Design → Create datum plane) or\n" + "switch to preset plane mode.", + ) + return + + try: + # Create cutter with the object selected in the dialog + cutter = SmartCutter(obj, params) + + # Execute with progress updates + def progress_update(value, message=""): + dialog.set_status(message) + dialog.set_progress(value) + + bottom_body, top_body = cutter.execute(progress_update) + + # Bodies are already created in the document by execute() + # Just need to finalize and hide original + + # Transaction 7: Hide original object + App.ActiveDocument.openTransaction("Hide Original Object") + try: + obj.ViewObject.Visibility = False + App.ActiveDocument.commitTransaction() + except Exception: + App.ActiveDocument.abortTransaction() + raise + + App.ActiveDocument.recompute() + + dialog.set_status( + f"Success! Created {bottom_body.Label} and {top_body.Label}\n" + f"Original object hidden. Holes are parametric - edit them in the feature tree." + ) + + App.Console.PrintMessage( + f"Cut complete: {bottom_body.Label}, {top_body.Label}\n" + f"Holes created as PartDesign::Hole features (editable in feature tree)\n" + ) + + except HolePlacementError as e: + dialog.set_status(f"Error: {e!s}", is_error=True) + App.Console.PrintError(f"Cut failed: {e!s}\n") + except Exception as e: + dialog.set_status(f"Unexpected error: {e!s}", is_error=True) + App.Console.PrintError(f"Unexpected error: {e!s}\n") + import traceback + + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg b/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg new file mode 100644 index 0000000..dae75e0 --- /dev/null +++ b/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md b/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md new file mode 100644 index 0000000..1cb63a3 --- /dev/null +++ b/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md @@ -0,0 +1,630 @@ +# Cut Object for Magnets - FreeCAD Macro + +![Macro Icon](CutObjectForMagnets.svg) + +**Version:** 1.0.0 +**FreeCAD Version:** 0.19 or later +**License:** MIT + +## Overview + +This FreeCAD macro intelligently cuts 3D objects along a plane and automatically places magnet holes (for magnets, dowels, pins, etc.) with built-in surface penetration detection. Unlike simple cutting tools, this macro ensures magnet holes won't accidentally break through the outer surface of your object. + +Perfect for creating multi-part prints that snap together with magnets or alignment pins! + +## Quick Visual Guide + +### Method 1: Preset Planes (Simple) + +```text +Select Object → Run Macro → Choose XY/XZ/YZ → Set Offset → Execute +``` + +Use for: Axis-aligned cuts, quick splits, standard orientations + +### Method 2: Model Planes (Advanced - ANY Angle) + +```text +Create Datum Plane (angled as needed) → Select Object → Run Macro → +Choose "Model Plane" → Select Your Plane → Execute +``` + +Use for: Angled cuts, following model geometry, complex orientations + +### Quick Tip: Select Both Object AND Plane Together + +```text +Select Object + Plane (Ctrl+Click) → Run Macro → Plane auto-selected! +``` + +The macro automatically detects when you select both an object and a plane together. + +--- + +## Features + +- **Smart Surface Detection** - Automatically skips holes that would penetrate the object's outer surface +- **Smart Repositioning** - When a hole would penetrate, tries nearby positions (further inward or along perimeter) +- **Dual-Part Validation** - Validates each hole position works for BOTH parts before creating any holes +- **Minimum Spacing** - Ensures holes are at least 2x diameter apart (one hole width between them) +- **Automatic Alignment** - Magnet holes on both pieces are perfectly aligned +- **Flexible Plane Selection** - Use preset planes (XY/XZ/YZ) OR any datum plane/face from your model +- **Angled Cuts** - Cut along any angle by selecting a datum plane +- **Even Distribution** - Holes are evenly spaced around the perimeter of the cut face +- **Count-Based Placement** - Specify exact number of holes (default: 6) for precise control +- **Safety Clearance** - Maintains minimum distance from outer surfaces +- **Visual Feedback** - Progress bar and status messages +- **Non-Destructive** - Original object is hidden, not deleted + +--- + +## Installation + +### macOS Installation (Detailed) + +#### Step 1: Locate Your FreeCAD Macros Folder + +The macros folder location on macOS: + +```text +~/Library/Application Support/FreeCAD/Macro/ +``` + +**How to access it:** + +**Option A - Finder (Recommended):** + +1. Open **Finder** +1. Press `Cmd + Shift + G` (Go to Folder) +1. Paste: `~/Library/Application Support/FreeCAD/Macro/` +1. Press **Enter** + +**Option B - Terminal:** + +```bash +mkdir -p ~/Library/Application\ Support/FreeCAD/Macro/ +open ~/Library/Application\ Support/FreeCAD/Macro/ +``` + +**Option C - From FreeCAD:** + +1. Open FreeCAD +1. Go to **Macro → Macros...** +1. Note the path shown at the top of the dialog +1. Click **User macros location** to open in Finder + +#### Step 2: Install the Macro File + +1. Save the macro file as `CutObjectForMagnets.FCMacro` in the macros folder +1. (Optional) Save the icon as `CutObjectForMagnets.svg` in the same folder + +#### Step 3: Verify Installation + +1. Open FreeCAD +1. Go to **Macro → Macros...** +1. You should see "CutObjectForMagnets" in the list +1. (Optional) Click **Edit** to view the macro code + +#### Step 4: Create a Toolbar Button (Optional but Recommended) + +1. Go to **Macro → Macros...** +1. Select **CutObjectForMagnets** +1. Click **Create** (toolbar button icon) +1. Choose the icon file (`CutObjectForMagnets.svg`) if you saved it +1. The macro will now appear in your toolbar for quick access + +--- + +### Linux Installation + +```bash +# Create macros directory if it doesn't exist +mkdir -p ~/.FreeCAD/Macro/ + +# Copy macro file +cp CutObjectForMagnets.FCMacro ~/.FreeCAD/Macro/ + +# (Optional) Copy icon +cp CutObjectForMagnets.svg ~/.FreeCAD/Macro/ +``` + +--- + +### Windows Installation + +1. Navigate to: `%APPDATA%\FreeCAD\Macro\` +1. Copy `CutObjectForMagnets.FCMacro` to this folder +1. (Optional) Copy `CutObjectForMagnets.svg` to the same folder + +--- + +## How to Use + +### Quick Start Guide + +#### Step 1: Prepare Your Model + +1. Open your model in FreeCAD (or create/import one) +1. Ensure the object is a **solid** (not a shell or surface) +1. **Select the object** in the 3D view or tree view + +**Tip:** If you imported an STL, it should already be a valid solid. If you created the model in FreeCAD, make sure you created a solid object in the Part workbench. + +#### Step 2: Launch the Macro + +- **From Macro Menu:** Macro → Macros... → Select "CutObjectForMagnets" → Execute +- **From Toolbar:** Click the macro icon (if you created a toolbar button) + +#### Step 3: Configure Cut Plane + +The dialog will open with several configuration sections. + +**Plane Type Settings:** + +You have two options for defining your cut plane: + +**Option 1: Preset Plane** (Simple, axis-aligned cuts) + +- **Plane:** Choose orientation + + - `XY` - Horizontal cut (most common) + - `XZ` - Vertical cut (front-to-back) + - `YZ` - Vertical cut (left-to-right) + +- **Offset:** Position of the cut plane from origin (in mm) + + - `0` - Cut through the origin + - Positive values move the plane in the positive axis direction + - Negative values move it in the negative axis direction + +**Finding the Right Offset:** + +1. Note your object's bounding box dimensions (visible in FreeCAD) +1. If your object is centered at origin and you want to cut in half: + - Use offset = `0` +1. If your object is positioned elsewhere: + - Look at the coordinate where you want to cut + - Use that value as the offset + +**Option 2: Model Plane** (Advanced, angled cuts) + +Select any datum plane or planar face from your model: + +**Using Datum Planes:** + +1. Create a datum plane first (if not already in model): + + - Go to **Part Design** workbench + - Click **Create datum plane** (or Part → Datum → Datum Plane) + - Position and angle the plane where you want to cut + - Exit datum plane creation + +1. In the macro dialog: + + - Select **"Model Plane"** from Plane Type + - Choose your datum plane from the dropdown + - The offset field is disabled (plane position defines the cut) + +**Using Object Faces:** + +- Any planar face on any object in your document can be used as a cut plane +- Select from the dropdown: "Face: [ObjectName] (Face1, Face2, etc.)" +- Useful for cutting along existing geometry + +**Examples of Model Plane Cuts:** + +- Cut at 45° angle - Create datum plane rotated 45° around X or Y axis +- Cut following surface contour - Select a planar face on reference geometry +- Cut perpendicular to cylinder axis - Use cylinder end face +- Complex multi-angle cuts - Create multiple datum planes for different cuts + +#### Step 4: Configure Magnet Holes + +**Hole Parameters:** + +- **Diameter:** The diameter of your magnet (mm) + + - For 6mm magnets: Use `6.2mm` (adds 0.2mm clearance) + - For 3mm dowels: Use `3.1mm` (adds 0.1mm clearance) + - For friction fit: Use exact diameter or slightly smaller + +- **Depth:** How deep holes go into each piece (mm) + + - For magnets: Use magnet thickness + 0.5mm + - For dowel pins: Usually half the dowel length + - **Important:** Each piece gets this depth + +- **Number of Holes:** Total number of magnet holes to create (default: 6) + + - Holes are evenly distributed around the perimeter of the cut face + - More holes = stronger joint, but requires more magnets + - Recommended: `4-6` for small objects, `8-12` for large objects + +- **Edge Clearance (Preferred):** Ideal distance from hole edge to outer surface (mm) + + - Default: `2mm` - holes are initially placed with this clearance + - This is the distance the macro tries to maintain for optimal part strength + - Larger values = more material around holes = stronger parts + +- **Edge Clearance (Minimum):** Absolute minimum acceptable clearance (mm) + + - Default: `0.5mm` - the smallest allowable clearance + - When smart repositioning can't place a hole at preferred clearance, it will try progressively smaller clearances down to this minimum + - Prevents holes from getting too close to outer surfaces + - For thin-walled objects: Increase this value to prevent breakthrough + +#### Step 5: Execute the Cut + +1. Review all parameters +1. Click **"Execute Cut"** +1. Watch the progress bar +1. Check status messages for any skipped holes + +#### Step 6: Review Results + +The macro creates two new objects: + +- `[ObjectName]_Bottom` - Lower piece with holes +- `[ObjectName]_Top` - Upper piece with holes + +Your original object is **hidden** (not deleted) - you can show it again from the tree view if needed. + +--- + +## Parameter Reference Guide + +### Understanding Edge Clearance + +The macro uses a **dual clearance system** to balance ideal hole placement with flexibility: + +- **Preferred Clearance (2mm default):** Initial hole placement uses this value +- **Minimum Clearance (0.5mm default):** Fallback when preferred clearance fails + +**How it works:** + +1. Holes are first placed using the preferred clearance +1. If a hole fails the safety check at preferred clearance, smart repositioning kicks in +1. The macro tries progressively smaller clearances (from preferred down to minimum) +1. Only if all clearance levels fail does the macro try moving the hole position + +The safety check ensures: + +```text +hole_radius + depth + clearance < distance_to_nearest_surface +``` + +**Visual Example:** + +```text + [Outer Surface] + | + clearance (preferred: 2mm, min: 0.5mm) + | + [Hole boundary] ← Must not touch outer surface + | + actual hole +``` + +If a hole would violate even the minimum clearance, it's automatically skipped and you'll see a warning in the console. + +### Calculating Depth for Magnets + +For magnets that should be flush or recessed: + +```text +depth = magnet_thickness + recess_amount + +Examples: +- 2mm thick magnet, flush: depth = 2.5mm (0.5mm tolerance) +- 3mm thick magnet, 0.5mm recess: depth = 3.5mm + 0.5mm = 4mm +``` + +**Important:** Total depth in both pieces should accommodate the magnet fully: + +```text +total_depth = depth_bottom + depth_top +total_depth should be >= magnet_thickness +``` + +### Recommended Hole Count Guidelines + +| Object Size | Recommended Holes | Notes | +| ----------------- | ----------------- | ---------------------------------- | +| Small (\<50mm) | 4-6 | Fewer holes for small surfaces | +| Medium (50-150mm) | 6-10 | Default of 6 works well | +| Large (>150mm) | 10-16 | More holes for stronger connection | +| Thin-walled | 4-6 | Use smaller diameter holes | + +--- + +## Common Use Cases + +### Case 1: Large Print Split for Bed Size + +**Scenario:** 300mm diameter object, need to split for 250mm print bed + +**Settings:** + +```text +Plane: XY +Offset: 0 (if centered) or half the object height +Diameter: 6.2mm (for 6×2mm magnets) +Depth: 2.5mm +Number of Holes: 12 +Clearance: 3mm +``` + +**Result:** Two pieces that stack vertically with 12 evenly-spaced magnets for alignment + +--- + +### Case 2: Modular Terrain Tiles + +**Scenario:** 100×100mm terrain tiles with magnetic edges + +**Settings:** + +```text +Plane: XY or YZ (depending on desired split) +Offset: 50mm (half the tile dimension) +Diameter: 3.2mm (for 3×1mm magnets) +Depth: 1.5mm +Number of Holes: 6 +Clearance: 2mm +``` + +**Result:** Tiles that connect magnetically at edges with 6 evenly-spaced magnets + +--- + +### Case 3: Dowel Pin Alignment for Large Parts + +**Scenario:** Splitting a large miniature or sculpture + +**Settings:** + +```text +Plane: XY +Offset: [at desired split point] +Diameter: 3.1mm (for 3mm brass pins) +Depth: 8mm (16mm pins, 8mm per side) +Number of Holes: 4 +Clearance: 3mm +``` + +**Result:** Precise alignment with 4 evenly-spaced removable pins + +--- + +### Case 4: Angled Cut with Datum Plane (45° Wedge) + +**Scenario:** Splitting a wedge-shaped console at a 45° angle + +**Preparation:** + +1. Switch to **Part Design** workbench +1. Create datum plane: + - Click **Create datum plane** (or Part → Datum → Create datum plane) + - In the dialog: + - Attachment mode: "Translate origin" + - Angle: Rotate 45° around X-axis + - Position: Move to desired cut location + - Click **OK** +1. Name it "CutPlane45" (optional but helpful) + +**Settings:** + +```text +Plane Type: Model Plane +Model Plane: Plane: CutPlane45 +Diameter: 6.2mm (for 6×2mm magnets) +Depth: 3mm +Number of Holes: 8 +Clearance: 3mm +``` + +**Result:** Clean 45° angled cut with 8 evenly-spaced magnet holes + +**Pro Tip:** You can visually see where the cut will be by looking at the datum plane position before running the macro! + +--- + +## Troubleshooting + +### Error: "Please select an object to cut" + +**Solution:** Click on your object in the 3D view before running the macro. + +--- + +### Error: "Selected object does not have a shape" + +**Solution:** You selected a group or annotation. Select the actual 3D object (should be under Part or Body in the tree). + +--- + +### Error: "Failed to cut object" + +**Possible causes:** + +1. **Invalid mesh** - The object has geometry errors + + - Try: Edit → Preferences → Part Design → "Automatically refine model after boolean operation" + - Or manually: Part → Refine Shape + +1. **Offset is outside object bounds** - The cut plane doesn't intersect the object + + - Check your object's position and bounding box + - Adjust offset to actually pass through the object + +1. **Model plane doesn't intersect** - The selected datum plane or face doesn't pass through the object + + - Verify the plane position in the 3D view + - Create a new datum plane that actually cuts through the object + +--- + +### Error: "No planes available" + +**Solution:** You selected "Model Plane" but no datum planes or planar faces exist in the document. + +**Fix:** + +1. Create a datum plane: + - **Part Design → Create datum plane** + - Position and angle as needed + - Click OK +1. Or switch to "Preset Plane" mode +1. Re-run the macro + +--- + +### Warning: "Skipping hole at [position] - would penetrate surface" + +**This is normal!** The macro is protecting you from holes that would break through. + +**If too many holes are skipped:** + +1. **Increase edge clearance** (e.g., from 2mm to 4mm) +1. **Increase spacing** (fewer holes, but safer) +1. **Reduce hole depth** (less likely to penetrate) +1. **Check your object** - might be too thin for the hole size + +--- + +### Error: "No valid hole positions found" + +**Possible causes:** + +1. **Cut surface too small** - Not enough room for even one hole + + - Solution: Reduce spacing or hole diameter + +1. **Object too thin** - All positions would penetrate + + - Solution: Reduce hole depth or increase clearance less aggressively + +1. **Complex geometry** - Cut surface is irregular + + - Solution: Manually place holes in FreeCAD after cutting + +--- + +## Advanced Tips & Best Practices + +### Creating Perfect Datum Planes + +**For Angled Cuts:** + +1. **Simple Rotation Method:** + + ```text + Part Design → Create datum plane + - Reference: XY plane (or any base plane) + - Attachment offset → Rotation: + - Around X: Tilts forward/back + - Around Y: Tilts left/right + - Around Z: Spins horizontally + - Position: Translation Z/Y/X to move the plane + ``` + +1. **Align to Edges/Vertices:** + + ```text + Part Design → Create datum plane + - Attachment mode: "Three points" + - Select 3 points on your model + - Plane will pass through all three points + ``` + +### Choosing the Right Clearance + +The dual clearance system gives you fine-grained control: + +**Preferred Clearance (default 2mm):** + +- Sets how far from edges holes are initially placed +- Increase for stronger parts (more material around holes) +- Decrease if you need holes closer to edges + +**Minimum Clearance (default 0.5mm):** + +- The absolute minimum acceptable distance from edges +- Increase for thin-walled objects to prevent breakthrough +- Should always be ≤ preferred clearance + +**Guidelines by object type:** + +- **Thin-walled objects** (2-4mm walls): Preferred=2mm, Minimum=1mm +- **Thick objects** (>10mm walls): Preferred=3mm, Minimum=0.5mm (defaults work well) +- **Irregular shapes:** Preferred=4-5mm, Minimum=2mm to be safe + +### Magnet Installation Tips + +After printing and cutting: + +1. Test fit magnets - they should slide in smoothly +1. If too tight: Use a drill bit to clean out the holes slightly +1. If too loose: Use CA glue or epoxy to secure +1. **Check polarity!** - Mark magnet orientation before gluing + +--- + +## Technical Details + +### How Surface Penetration Detection Works + +For each potential hole position, the macro: + +1. Creates a test cylinder with radius = `(diameter/2) + clearance` +1. Extends the cylinder to depth = `hole_depth + clearance` +1. Performs boolean intersection with the part +1. If intersection volume < 99% of test cylinder volume → hole would penetrate → skip it + +This ensures holes only go where they're safe! + +### Coordinate System + +- **Origin (0,0,0):** FreeCAD document origin +- **Cut planes** pass through a point at the specified offset: + - XY plane: Point = (0, 0, offset) + - XZ plane: Point = (0, offset, 0) + - YZ plane: Point = (offset, 0, 0) + +### Hole Direction + +- **Bottom piece:** Holes point UP (toward cut plane) +- **Top piece:** Holes point DOWN (toward cut plane) +- Both sets align perfectly at the cut interface + +--- + +## Version History + +### v1.0.0 (2025-01-01) + +- Initial release +- XY, XZ, YZ preset plane support +- Model plane support (datum planes and planar faces) +- Angled cut capability via datum planes +- Surface penetration detection +- Configurable hole parameters +- Progress tracking +- Dual-part validation +- Minimum spacing enforcement (2x diameter) +- Smart hole repositioning +- Object selection combo box + +--- + +## License + +MIT License - Free to use, modify, and distribute. + +--- + +## Credits + +Created for the FreeCAD community to make multi-part 3D printing easier and more reliable. + +**Inspiration:** PrusaSlicer's cut tool, but with reliable export and better surface detection. diff --git a/macros/Start_MCP_Bridge/README-StartMCPBridge.md b/macros/Start_MCP_Bridge/README-StartMCPBridge.md new file mode 100644 index 0000000..2f6e746 --- /dev/null +++ b/macros/Start_MCP_Bridge/README-StartMCPBridge.md @@ -0,0 +1,138 @@ +# Start MCP Bridge - FreeCAD Macro + +**Version:** 1.0.0 +**FreeCAD Version:** 0.21 or later +**License:** MIT + +## Overview + +This FreeCAD macro starts the MCP (Model Context Protocol) bridge server, enabling integration between AI assistants (Claude, GPT, and other MCP-compatible tools) and FreeCAD. Once running, AI assistants can control FreeCAD, create and modify 3D models, execute Python code, and more. + +## Quick Start + +### Installation + +```bash +# From the freecad-mcp project directory +just install-bridge-macro +``` + +### Usage + +1. Start FreeCAD +1. Go to **Macro -> Macros...** +1. Select **StartMCPBridge** +1. Click **Execute** + +You should see in the FreeCAD console: + +```text +MCP Bridge started! + - XML-RPC: localhost:9875 + - Socket: localhost:9876 + +You can now use AI assistants with FreeCAD. +``` + +### Connecting Your MCP Client + +After starting the bridge, configure your MCP client (e.g., Claude Code) with: + +```json +{ + "mcpServers": { + "freecad": { + "command": "uv", + "args": ["run", "--project", "/path/to/freecad-mcp", "freecad-mcp"], + "env": { + "FREECAD_MODE": "xmlrpc" + } + } + } +} +``` + +## Connection Modes + +The bridge starts two servers: + +| Port | Protocol | Description | +| ---- | -------- | ------------------------------------- | +| 9875 | XML-RPC | Primary connection mode (recommended) | +| 9876 | JSON-RPC | Alternative socket-based connection | + +Configure your MCP client's `FREECAD_MODE` environment variable: + +- `xmlrpc` (default) - Uses port 9875 +- `socket` - Uses port 9876 + +## Alternative: Automatic Startup + +Instead of running the macro manually each time, you can: + +### Option 1: Use `just run-gui` + +```bash +just run-gui +``` + +This starts FreeCAD with the bridge auto-started. + +### Option 2: Use `just run-headless` + +```bash +just run-headless +``` + +This starts FreeCAD in headless/console mode with the bridge running. Useful for automation and CI/CD. + +## Troubleshooting + +### Error: "MCP Bridge macro not properly installed" + +The macro wasn't installed correctly. Run: + +```bash +just install-bridge-macro +``` + +### Error: "Failed to import MCP Bridge module" + +The freecad-mcp project path is incorrect or the module isn't installed. Ensure: + +1. You installed using `just install-bridge-macro` from the project directory +1. The project's Python dependencies are installed (`uv sync`) + +### Bridge won't start + +Check the FreeCAD console for error messages. Common issues: + +1. **Port already in use** - Another instance is running, or another application is using ports 9875/9876 +1. **Python path issues** - The project src directory isn't accessible + +### MCP client can't connect + +1. Ensure the bridge is running (check FreeCAD console) +1. Verify your MCP client configuration +1. Restart your MCP client after configuration changes + +## Uninstallation + +```bash +just uninstall-bridge-macro +``` + +## Technical Details + +The macro: + +1. Adds the freecad-mcp project source to Python's path +1. Imports and instantiates the `FreecadMCPPlugin` +1. Starts both XML-RPC and JSON-RPC servers +1. Registers handlers for executing Python code, managing documents, creating objects, etc. + +The bridge runs in FreeCAD's main thread using Qt timers for non-blocking operation. + +## License + +MIT License - Free to use, modify, and distribute. diff --git a/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro b/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro new file mode 100644 index 0000000..8c9dcbb --- /dev/null +++ b/macros/Start_MCP_Bridge/StartMCPBridge.FCMacro @@ -0,0 +1,69 @@ +"""FreeCAD Macro: Start MCP Bridge Server. + +Start the MCP bridge server for AI assistant integration with FreeCAD. + +Version: 1.0.0 +Author: FreeCAD MCP Project + +Requirements: + - FreeCAD 0.21 or later + - freecad-mcp project installed and accessible + +Usage: + 1. Install via: just install-bridge-macro + 2. Run the macro from: Macro -> Macros -> StartMCPBridge -> Execute + 3. Connect your MCP client (Claude Code, etc.) to the running bridge +""" + +import sys + +import FreeCAD + + +def start_mcp_bridge(): + """Start the MCP bridge server for AI assistant integration.""" + # The project path is injected during macro installation + # This placeholder will be replaced with the actual path + project_path = "__PROJECT_PATH__" + + if project_path == "__PROJECT_PATH__": + FreeCAD.Console.PrintError( + "MCP Bridge macro not properly installed.\n" + "Please run: just install-bridge-macro\n" + ) + return + + if project_path not in sys.path: + sys.path.insert(0, project_path) + + try: + # Import and start the plugin + from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin + + # Create and start the plugin + plugin = FreecadMCPPlugin( + host="localhost", + port=9876, # JSON-RPC socket port + xmlrpc_port=9875, # XML-RPC port (neka-nat compatible) + enable_xmlrpc=True, + ) + plugin.start() + + FreeCAD.Console.PrintMessage("MCP Bridge started!\n") + FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n") + FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n") + FreeCAD.Console.PrintMessage( + "\nYou can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n" + ) + + except ImportError as e: + FreeCAD.Console.PrintError( + f"Failed to import MCP Bridge module: {e}\n" + f"Ensure the freecad-mcp project is accessible at: {project_path}\n" + ) + except Exception as e: + FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n") + + +if __name__ == "__main__": + start_mcp_bridge() diff --git a/macros/Start_MCP_Bridge/StartMCPBridge.svg b/macros/Start_MCP_Bridge/StartMCPBridge.svg new file mode 100644 index 0000000..6debfd5 --- /dev/null +++ b/macros/Start_MCP_Bridge/StartMCPBridge.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MCP + + + + + + diff --git a/mkdocs.yaml b/mkdocs.yaml new file mode 100644 index 0000000..8d648ca --- /dev/null +++ b/mkdocs.yaml @@ -0,0 +1,75 @@ +site_name: FreeCAD MCP Server +site_description: MCP server for FreeCAD integration with AI assistants +site_url: https://github.com/spkane/freecad-mcp +repo_url: https://github.com/spkane/freecad-mcp +repo_name: spkane/freecad-mcp + +theme: + name: material + palette: + - scheme: default + primary: blue + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: blue + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - content.code.copy + - content.code.annotate + - navigation.instant + - navigation.tracking + - navigation.tabs + - navigation.sections + - toc.integrate + - search.suggest + - search.highlight + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.snippets + - admonition + - pymdownx.details + - attr_list + - md_in_html + - tables + - toc: + permalink: true + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_source: true + show_root_heading: true + show_category_heading: true + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Configuration: getting-started/configuration.md + - Quick Start: getting-started/quickstart.md + - User Guide: + - Embedded Mode: guide/embedded-mode.md + - Socket Mode: guide/socket-mode.md + - Tools Reference: guide/tools.md + - Resources Reference: guide/resources.md + - API Reference: + - Server: api/server.md + - Bridge: api/bridge.md + - Config: api/config.md + - Development: + - Contributing: development/contributing.md + - Architecture: development/architecture.md diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7596fc3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,284 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "freecad-robust-mcp" +version = "0.1.0" +description = "MCP (Model Context Protocol) server for FreeCAD integration with Claude Code and other AI assistants" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +authors = [ + { name = "Sean P. Kane", email = "spkane@gmail.com" } +] +maintainers = [ + { name = "Sean P. Kane", email = "spkane@gmail.com" } +] +keywords = [ + "freecad", + "mcp", + "model-context-protocol", + "claude", + "claude-code", + "ai", + "llm", + "cad", + "3d-modeling", + "3d-printing", + "parametric-design", + "automation", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Intended Audience :: Manufacturing", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Visualization", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +dependencies = [ + "mcp>=1.25.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.7.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.25.0", + "pytest-cov>=6.0.0", + "pytest-timeout>=2.3.0", + "mypy>=1.14.0", + "ruff>=0.8.0", + "bandit[toml]>=1.8.0", + "safety>=3.2.0", + "pre-commit>=4.0.0", + "mkdocs>=1.6.0", + "mkdocs-material>=9.5.0", + "mkdocstrings[python]>=0.27.0", + "md-toc>=9.0.0", + "twine>=6.0.0", + "build>=1.2.0", +] + +[project.scripts] +freecad-mcp = "freecad_mcp.server:main" + +[project.urls] +Homepage = "https://github.com/spkane/freecad-mcp" +Documentation = "https://github.com/spkane/freecad-mcp#readme" +Repository = "https://github.com/spkane/freecad-mcp.git" +Issues = "https://github.com/spkane/freecad-mcp/issues" +Changelog = "https://github.com/spkane/freecad-mcp/blob/main/CHANGELOG.md" +"Docker Hub" = "https://hub.docker.com/r/spkane/freecad-mcp" + +[tool.hatch.build.targets.wheel] +packages = ["src/freecad_mcp"] + +[tool.ruff] +target-version = "py311" +line-length = 88 +src = ["src", "tests", "macros"] +extend-include = ["*.FCMacro"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "PTH", # flake8-use-pathlib + "ERA", # eradicate (commented-out code) + "PL", # Pylint + "RUF", # Ruff-specific rules + "S", # flake8-bandit (security) + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "D", # pydocstyle +] +ignore = [ + "D104", # Missing docstring in public package - __init__.py often just imports + "D107", # Missing docstring in __init__ - class docstring covers this + "ANN401", # Allow Any in type annotations - FreeCAD types are dynamic + "ANN001", # Allow missing type annotations - FastMCP doesn't export types + "PLR0913",# Too many arguments - CAD APIs have many params + "PLR0915",# Too many statements - registration functions are long + "PLC0415",# Allow imports in functions - FreeCAD must be imported lazily + "PLR2004",# Allow magic numbers in comparisons - CAD dimensions + "PLW0603",# Allow global statement - bridge lifecycle management + "E501", # Line too long - embedded Python code strings + "RUF022", # Don't sort __all__ - prefer logical grouping + "S110", # Allow try-except-pass for optional operations + "ARG002", # Allow unused arguments - needed for interface compliance +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = [ + "S101", # Allow asserts in tests + "S108", # Allow /tmp in tests + "ANN", # Don't require annotations in tests + "D", # Don't require docstrings in tests + "PLR2004",# Allow magic numbers in tests + "SIM105", # Allow try-except-pass in tests + "SIM117", # Allow nested with statements + "B017", # Allow blind exception catch in tests +] +# FreeCAD macros have relaxed rules - they are standalone GUI scripts +# But still require essential docstrings (module, class, public function) +"**/*.FCMacro" = [ + "ANN", # Don't require type annotations - FreeCAD types are complex + "D104", # Missing docstring in public package - N/A for macros + "D105", # Missing docstring in magic method - __init__ etc don't need them + "D107", # Missing docstring in __init__ - class docstring covers this + "D203", # Blank line before class docstring (conflicts with D211) + "D212", # Multi-line docstring start (conflicts with D213) + "PLR0912",# Allow many branches - GUI code is complex + "PLR0913",# Allow many arguments - CAD APIs have many params + "PLR0915",# Allow many statements - macros are self-contained + "SIM102", # Allow nested if statements - improves readability + "SIM103", # Allow return after if-else for clarity + "SIM108", # Allow if-else over ternary for readability + "S112", # Allow try-except-continue - used for geometry iteration + "F841", # Allow unused variables - Qt signal connections + "UP008", # Allow super(Class, self) - PySide compatibility +] + +[tool.mypy] +python_version = "3.11" +# Not using strict mode due to FastMCP lacking type stubs +warn_return_any = false +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +disallow_untyped_decorators = false +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = false +warn_no_return = true +follow_imports = "silent" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[[tool.mypy.overrides]] +module = [ + "freecad_mcp.tools", + "freecad_mcp.tools.*", +] +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false + +[[tool.mypy.overrides]] +module = [ + "freecad_mcp.resources", + "freecad_mcp.resources.*", +] +disallow_untyped_decorators = false +disallow_untyped_defs = false +warn_return_any = false + +[[tool.mypy.overrides]] +module = [ + "freecad_mcp.prompts", + "freecad_mcp.prompts.*", +] +disallow_untyped_decorators = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = [ + "freecad_mcp.bridge", + "freecad_mcp.bridge.*", +] +warn_return_any = false + +[[tool.mypy.overrides]] +module = [ + "freecad_mcp.freecad_plugin", + "freecad_mcp.freecad_plugin.*", +] +warn_return_any = false +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = "freecad_mcp.server" +disallow_untyped_decorators = false +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +addopts = [ + "-ra", + "-q", + "--strict-markers", + "--strict-config", +] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks integration tests requiring FreeCAD", + "gui: marks tests requiring FreeCAD GUI mode (not headless)", +] + +[tool.coverage.run] +source = ["src/freecad_mcp"] +branch = true +parallel = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", + "@abstractmethod", +] +show_missing = true +fail_under = 80 + +[tool.bandit] +exclude_dirs = ["tests", "docs"] +skips = [ + "B101", # Allow asserts + "B102", # exec is required for FreeCAD Python execution + "B110", # try-except-pass used for optional cleanup + "B411", # XML-RPC is required for FreeCAD compatibility +] + +[tool.codespell] +skip = ".git,*.lock,*.toml" +ignore-words-list = "crate" diff --git a/src/freecad_mcp/__init__.py b/src/freecad_mcp/__init__.py new file mode 100644 index 0000000..71c272d --- /dev/null +++ b/src/freecad_mcp/__init__.py @@ -0,0 +1,24 @@ +"""FreeCAD MCP Server - AI assistant integration for FreeCAD. + +This package provides an MCP (Model Context Protocol) server that enables +integration between AI assistants (Claude, GPT, etc.) and FreeCAD, allowing +AI-assisted development and debugging of 3D models, macros, and workbenches. + +Example: + Run the MCP server:: + + $ freecad-mcp + + Or with Python:: + + >>> from freecad_mcp.server import main + >>> main() +""" + +__version__ = "0.1.0" +__author__ = "Sean P. Kane" +__email__ = "spkane@gmail.com" + +from freecad_mcp.server import mcp + +__all__ = ["__version__", "mcp"] diff --git a/src/freecad_mcp/bridge/__init__.py b/src/freecad_mcp/bridge/__init__.py new file mode 100644 index 0000000..cfa5cfc --- /dev/null +++ b/src/freecad_mcp/bridge/__init__.py @@ -0,0 +1,48 @@ +"""FreeCAD bridge implementations. + +This package provides bridge implementations for communicating with FreeCAD +in different modes (embedded, XML-RPC, and socket-based). + +Bridge Modes: +- EmbeddedBridge: In-process FreeCAD for headless operation (fastest) +- XmlRpcBridge: XML-RPC protocol for GUI mode (neka-nat compatible) +- SocketBridge: JSON-RPC over TCP for modern, lightweight communication +""" + +from freecad_mcp.bridge.base import ( + ConnectionStatus, + DocumentInfo, + ExecutionResult, + FreecadBridge, + MacroInfo, + ObjectInfo, + ObjectType, + ScreenshotResult, + ShapeInfo, + ViewAngle, + WorkbenchInfo, +) +from freecad_mcp.bridge.embedded import EmbeddedBridge +from freecad_mcp.bridge.socket import JsonRpcError, SocketBridge +from freecad_mcp.bridge.xmlrpc import XmlRpcBridge + +__all__ = [ + # Base classes and types + "ConnectionStatus", + "DocumentInfo", + "ExecutionResult", + "FreecadBridge", + "MacroInfo", + "ObjectInfo", + "ObjectType", + "ScreenshotResult", + "ShapeInfo", + "ViewAngle", + "WorkbenchInfo", + # Bridge implementations + "EmbeddedBridge", + "XmlRpcBridge", + "SocketBridge", + # Exceptions + "JsonRpcError", +] diff --git a/src/freecad_mcp/bridge/base.py b/src/freecad_mcp/bridge/base.py new file mode 100644 index 0000000..0e8614a --- /dev/null +++ b/src/freecad_mcp/bridge/base.py @@ -0,0 +1,611 @@ +"""Abstract bridge interface for FreeCAD communication. + +This module defines the abstract base class and data types for all FreeCAD +bridge implementations. Bridges provide the communication layer between +the MCP server and FreeCAD instances. + +Based on learnings from existing implementations: +- neka-nat: Queue-based thread safety for GUI operations +- jango: Multiple connection modes with recovery +- contextform: Comprehensive CAD operations +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class ViewAngle(str, Enum): + """Standard view angles for screenshots.""" + + ISOMETRIC = "Isometric" + FRONT = "Front" + BACK = "Back" + TOP = "Top" + BOTTOM = "Bottom" + LEFT = "Left" + RIGHT = "Right" + FIT_ALL = "FitAll" + + +class ObjectType(str, Enum): + """FreeCAD object type categories.""" + + PART = "Part" + PART_DESIGN = "PartDesign" + DRAFT = "Draft" + SKETCHER = "Sketcher" + FEM = "Fem" + MESH = "Mesh" + SPREADSHEET = "Spreadsheet" + + +@dataclass +class ExecutionResult: + """Result of Python code execution in FreeCAD. + + Attributes: + success: Whether execution completed without errors. + result: The value assigned to `_result_` variable, or None. + stdout: Captured standard output. + stderr: Captured standard error. + execution_time_ms: Time taken in milliseconds. + error_type: Type of exception if failed, None otherwise. + error_traceback: Full traceback if failed, None otherwise. + """ + + success: bool + result: Any + stdout: str + stderr: str + execution_time_ms: float + error_type: str | None = None + error_traceback: str | None = None + + +@dataclass +class DocumentInfo: + """Information about a FreeCAD document. + + Attributes: + name: Internal document name (identifier). + label: Display label (may differ from name). + path: File path if saved, None otherwise. + objects: List of object names in the document. + is_modified: Whether document has unsaved changes. + active_object: Name of the currently active object. + """ + + name: str + label: str = "" + path: str | None = None + objects: list[str] = field(default_factory=list) + is_modified: bool = False + active_object: str | None = None + + def __post_init__(self) -> None: + """Set label to name if not provided.""" + if not self.label: + self.label = self.name + + +@dataclass +class ObjectInfo: + """Information about a FreeCAD object. + + Attributes: + name: Object name (identifier). + label: Display label. + type_id: FreeCAD TypeId string (e.g., "Part::Box"). + properties: Dictionary of property names to values. + shape_info: Shape geometry details if applicable. + children: List of child object names (OutList). + parents: List of parent object names (InList). + visibility: Whether object is visible in the view. + """ + + name: str + label: str + type_id: str + properties: dict[str, Any] = field(default_factory=dict) + shape_info: dict[str, Any] | None = None + children: list[str] = field(default_factory=list) + parents: list[str] = field(default_factory=list) + visibility: bool = True + + +@dataclass +class ShapeInfo: + """Detailed shape geometry information. + + Attributes: + shape_type: Type of shape (Solid, Shell, Face, etc.). + volume: Volume of the shape (for solids). + area: Surface area of the shape. + center_of_mass: Center of mass coordinates. + bounding_box: Bounding box as (min, max) tuples. + is_valid: Whether the shape is geometrically valid. + is_closed: Whether the shape is closed. + vertex_count: Number of vertices. + edge_count: Number of edges. + face_count: Number of faces. + """ + + shape_type: str + volume: float | None = None + area: float | None = None + center_of_mass: tuple[float, float, float] | None = None + bounding_box: ( + tuple[tuple[float, float, float], tuple[float, float, float]] | None + ) = None + is_valid: bool = True + is_closed: bool = False + vertex_count: int = 0 + edge_count: int = 0 + face_count: int = 0 + + +@dataclass +class ScreenshotResult: + """Result of a screenshot capture. + + Attributes: + success: Whether screenshot was captured successfully. + data: Base64-encoded image data. + format: Image format (png, jpg). + width: Image width in pixels. + height: Image height in pixels. + view_angle: The view angle used. + error: Error message if failed. + """ + + success: bool + data: str | None = None + format: str = "png" + width: int = 0 + height: int = 0 + view_angle: ViewAngle | None = None + error: str | None = None + + +@dataclass +class MacroInfo: + """Information about a FreeCAD macro. + + Attributes: + name: Macro name (without extension). + path: Full path to macro file. + description: Macro description from comments. + is_system: Whether it's a system macro. + """ + + name: str + path: str + description: str = "" + is_system: bool = False + + +@dataclass +class WorkbenchInfo: + """Information about a FreeCAD workbench. + + Attributes: + name: Workbench internal name. + label: Display label. + icon: Icon resource path. + is_active: Whether workbench is currently active. + """ + + name: str + label: str + icon: str = "" + is_active: bool = False + + +@dataclass +class ConnectionStatus: + """Status of the FreeCAD connection. + + Attributes: + connected: Whether connection is established. + mode: Connection mode (embedded, xmlrpc, socket). + freecad_version: FreeCAD version string. + gui_available: Whether GUI is available. + last_ping_ms: Last ping latency in milliseconds. + error: Connection error message if any. + """ + + connected: bool + mode: str + freecad_version: str = "" + gui_available: bool = False + last_ping_ms: float = 0 + error: str | None = None + + +class FreecadBridge(ABC): + """Abstract base class for FreeCAD bridges. + + A bridge provides communication between the MCP server and a FreeCAD + instance. Implementations may run FreeCAD in-process (embedded), + communicate via XML-RPC, or use JSON-RPC over sockets. + + Thread Safety: + GUI operations must be executed on the main thread. Implementations + should use queue-based communication for thread safety (learned from + neka-nat implementation). + """ + + @abstractmethod + async def connect(self) -> None: + """Establish connection to FreeCAD. + + Raises: + ConnectionError: If connection cannot be established. + """ + + @abstractmethod + async def disconnect(self) -> None: + """Close connection to FreeCAD. + + Should be called during cleanup to release resources. + """ + + @abstractmethod + async def is_connected(self) -> bool: + """Check if bridge is connected to FreeCAD. + + Returns: + True if connected, False otherwise. + """ + + @abstractmethod + async def ping(self) -> float: + """Ping FreeCAD to check connection and measure latency. + + Returns: + Round-trip time in milliseconds. + + Raises: + ConnectionError: If not connected. + """ + + @abstractmethod + async def get_status(self) -> ConnectionStatus: + """Get detailed connection status. + + Returns: + ConnectionStatus with full status information. + """ + + # ========================================================================= + # Code Execution + # ========================================================================= + + @abstractmethod + async def execute_python( + self, + code: str, + timeout_ms: int = 30000, + ) -> ExecutionResult: + """Execute Python code in FreeCAD context. + + The code runs with access to FreeCAD modules (FreeCAD, App, Gui). + To return a value, assign it to the `_result_` variable. + + Args: + code: Python code to execute. + timeout_ms: Maximum execution time in milliseconds. + + Returns: + ExecutionResult with success status, output, and any errors. + """ + + # ========================================================================= + # Document Management + # ========================================================================= + + @abstractmethod + async def get_documents(self) -> list[DocumentInfo]: + """Get list of open documents. + + Returns: + List of DocumentInfo for each open document. + """ + + @abstractmethod + async def get_active_document(self) -> DocumentInfo | None: + """Get the active document. + + Returns: + DocumentInfo for active document, or None if no document is active. + """ + + @abstractmethod + async def create_document( + self, name: str, label: str | None = None + ) -> DocumentInfo: + """Create a new document. + + Args: + name: Internal document name (no spaces). + label: Display label (optional, defaults to name). + + Returns: + DocumentInfo for the created document. + """ + + @abstractmethod + async def open_document(self, path: str) -> DocumentInfo: + """Open an existing document. + + Args: + path: Path to the .FCStd file. + + Returns: + DocumentInfo for the opened document. + + Raises: + FileNotFoundError: If file doesn't exist. + ValueError: If file is not a valid FreeCAD document. + """ + + @abstractmethod + async def save_document( + self, + doc_name: str | None = None, + path: str | None = None, + ) -> str: + """Save a document. + + Args: + doc_name: Document name (uses active if None). + path: Save path (uses existing path if None). + + Returns: + Path where document was saved. + + Raises: + ValueError: If document not found or no path specified for new doc. + """ + + @abstractmethod + async def close_document(self, doc_name: str | None = None) -> None: + """Close a document. + + Args: + doc_name: Document name (uses active if None). + """ + + # ========================================================================= + # Object Management + # ========================================================================= + + @abstractmethod + async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]: + """Get all objects in a document. + + Args: + doc_name: Document name (uses active if None). + + Returns: + List of ObjectInfo for each object. + """ + + @abstractmethod + async def get_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> ObjectInfo: + """Get detailed object information. + + Args: + obj_name: Name of the object. + doc_name: Document name (uses active if None). + + Returns: + ObjectInfo with full object details. + + Raises: + ValueError: If object not found. + """ + + @abstractmethod + async def create_object( + self, + type_id: str, + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None, + ) -> ObjectInfo: + """Create a new object. + + Args: + type_id: FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder"). + name: Object name (auto-generated if None). + properties: Initial property values. + doc_name: Target document (uses active if None). + + Returns: + ObjectInfo for the created object. + """ + + @abstractmethod + async def edit_object( + self, + obj_name: str, + properties: dict[str, Any], + doc_name: str | None = None, + ) -> ObjectInfo: + """Edit object properties. + + Args: + obj_name: Name of the object to edit. + properties: Property values to set. + doc_name: Document name (uses active if None). + + Returns: + Updated ObjectInfo. + + Raises: + ValueError: If object not found. + """ + + @abstractmethod + async def delete_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> None: + """Delete an object. + + Args: + obj_name: Name of the object to delete. + doc_name: Document name (uses active if None). + + Raises: + ValueError: If object not found. + """ + + # ========================================================================= + # View and Screenshot + # ========================================================================= + + @abstractmethod + async def get_screenshot( + self, + view_angle: ViewAngle | None = None, + width: int = 800, + height: int = 600, + doc_name: str | None = None, + ) -> ScreenshotResult: + """Capture a screenshot of the 3D view. + + Args: + view_angle: View angle to set before capture. + width: Image width in pixels. + height: Image height in pixels. + doc_name: Document name (uses active if None). + + Returns: + ScreenshotResult with image data or error. + """ + + @abstractmethod + async def set_view( + self, + view_angle: ViewAngle, + doc_name: str | None = None, + ) -> None: + """Set the 3D view angle. + + Args: + view_angle: View angle to set. + doc_name: Document name (uses active if None). + """ + + # ========================================================================= + # Macros + # ========================================================================= + + @abstractmethod + async def get_macros(self) -> list[MacroInfo]: + """Get list of available macros. + + Returns: + List of MacroInfo for each macro. + """ + + @abstractmethod + async def run_macro( + self, + macro_name: str, + args: dict[str, Any] | None = None, + ) -> ExecutionResult: + """Run a macro by name. + + Args: + macro_name: Macro name (without .FCMacro extension). + args: Arguments to pass to the macro. + + Returns: + ExecutionResult from macro execution. + """ + + @abstractmethod + async def create_macro( + self, + name: str, + code: str, + description: str = "", + ) -> MacroInfo: + """Create a new macro. + + Args: + name: Macro name (without extension). + code: Python code for the macro. + description: Macro description. + + Returns: + MacroInfo for the created macro. + """ + + # ========================================================================= + # Workbenches + # ========================================================================= + + @abstractmethod + async def get_workbenches(self) -> list[WorkbenchInfo]: + """Get list of available workbenches. + + Returns: + List of WorkbenchInfo for each workbench. + """ + + @abstractmethod + async def activate_workbench(self, workbench_name: str) -> None: + """Activate a workbench. + + Args: + workbench_name: Workbench internal name. + + Raises: + ValueError: If workbench not found. + """ + + # ========================================================================= + # Version and Environment + # ========================================================================= + + @abstractmethod + async def get_freecad_version(self) -> dict[str, Any]: + """Get FreeCAD version information. + + Returns: + Dictionary with version, build_date, python_version, gui_available. + """ + + @abstractmethod + async def is_gui_available(self) -> bool: + """Check if FreeCAD GUI is available. + + Returns: + True if GUI is available, False for headless mode. + """ + + # ========================================================================= + # Console + # ========================================================================= + + @abstractmethod + async def get_console_output(self, lines: int = 100) -> list[str]: + """Get recent console output. + + Args: + lines: Maximum number of lines to return. + + Returns: + List of console output lines, most recent last. + """ diff --git a/src/freecad_mcp/bridge/embedded.py b/src/freecad_mcp/bridge/embedded.py new file mode 100644 index 0000000..d9b6b1d --- /dev/null +++ b/src/freecad_mcp/bridge/embedded.py @@ -0,0 +1,1064 @@ +"""Embedded bridge - runs FreeCAD in-process. + +This bridge imports FreeCAD directly into the MCP server process, +providing the fastest execution but limited to headless mode. + +Based on learnings from competitive analysis: +- Thread-safe execution using ThreadPoolExecutor (from neka-nat) +- Comprehensive object info including shape geometry +- Macro support with templates and validation +""" + +import asyncio +import io +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from typing import Any + +from freecad_mcp.bridge.base import ( + ConnectionStatus, + DocumentInfo, + ExecutionResult, + FreecadBridge, + MacroInfo, + ObjectInfo, + ScreenshotResult, + ViewAngle, + WorkbenchInfo, +) + + +class EmbeddedBridge(FreecadBridge): + """Bridge that runs FreeCAD embedded in the MCP server process. + + This bridge imports FreeCAD directly, providing fast execution + but only supports headless mode (no GUI). + + Attributes: + freecad_path: Optional path to FreeCAD's lib directory. + """ + + def __init__(self, freecad_path: str | None = None) -> None: + """Initialize the embedded bridge. + + Args: + freecad_path: Path to FreeCAD's lib directory. If provided, + this path will be added to sys.path before importing. + """ + self._freecad_path = freecad_path + self._fc_module: Any = None + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="freecad") + self._connected = False + + async def connect(self) -> None: + """Import and initialize FreeCAD. + + Raises: + ConnectionError: If FreeCAD cannot be imported. + """ + if self._freecad_path: + sys.path.insert(0, self._freecad_path) + + loop = asyncio.get_event_loop() + try: + self._fc_module = await loop.run_in_executor( + self._executor, + self._import_freecad, + ) + self._connected = True + except ImportError as e: + msg = f"Failed to import FreeCAD: {e}" + raise ConnectionError(msg) from e + + def _import_freecad(self) -> Any: + """Import FreeCAD module (runs in thread pool).""" + import FreeCAD + + return FreeCAD + + async def disconnect(self) -> None: + """Clean up resources.""" + self._connected = False + self._executor.shutdown(wait=True) + + async def is_connected(self) -> bool: + """Check if FreeCAD is imported and available.""" + return self._connected and self._fc_module is not None + + async def execute_python( + self, + code: str, + timeout_ms: int = 30000, + ) -> ExecutionResult: + """Execute Python code in FreeCAD context. + + Args: + code: Python code to execute. + timeout_ms: Maximum execution time in milliseconds. + + Returns: + ExecutionResult with execution outcome. + """ + if not self._connected: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr="FreeCAD bridge not connected", + execution_time_ms=0, + error_type="ConnectionError", + error_traceback=None, + ) + + loop = asyncio.get_event_loop() + + try: + result = await asyncio.wait_for( + loop.run_in_executor( + self._executor, + lambda: self._execute_code(code), + ), + timeout=timeout_ms / 1000, + ) + except TimeoutError: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=f"Execution timed out after {timeout_ms}ms", + execution_time_ms=float(timeout_ms), + error_type="TimeoutError", + error_traceback=None, + ) + + return result + + def _execute_code(self, code: str) -> ExecutionResult: + """Execute code synchronously (runs in thread pool).""" + start = time.perf_counter() + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + # Build execution context + exec_globals: dict[str, Any] = { + "FreeCAD": self._fc_module, + "App": self._fc_module, + "__builtins__": __builtins__, + } + + # Try to add GUI module if available + try: + import FreeCADGui + + exec_globals["FreeCADGui"] = FreeCADGui + exec_globals["Gui"] = FreeCADGui + except ImportError: + pass + + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + compiled = compile(code, "", "exec") + exec(compiled, exec_globals) # noqa: S102 + + elapsed = (time.perf_counter() - start) * 1000 + + return ExecutionResult( + success=True, + result=exec_globals.get("_result_"), + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + execution_time_ms=elapsed, + ) + + except Exception as e: + import traceback + + elapsed = (time.perf_counter() - start) * 1000 + + return ExecutionResult( + success=False, + result=None, + stdout=stdout_capture.getvalue(), + stderr=stderr_capture.getvalue(), + execution_time_ms=elapsed, + error_type=type(e).__name__, + error_traceback=traceback.format_exc(), + ) + + async def get_documents(self) -> list[DocumentInfo]: + """Get list of open documents.""" + result = await self.execute_python( + """ +_result_ = [] +for doc in FreeCAD.listDocuments().values(): + _result_.append({ + "name": doc.Name, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "label": doc.Label, + }) +""" + ) + + if result.success and result.result: + return [DocumentInfo(**doc) for doc in result.result] + return [] + + async def get_active_document(self) -> DocumentInfo | None: + """Get the active document.""" + result = await self.execute_python( + """ +doc = FreeCAD.ActiveDocument +if doc: + _result_ = { + "name": doc.Name, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "label": doc.Label, + } +else: + _result_ = None +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + return None + + async def get_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> ObjectInfo: + """Get detailed object information.""" + result = await self.execute_python( + f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +props = {{}} +for prop in obj.PropertiesList: + try: + val = getattr(obj, prop) + if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins': + val = str(val) + props[prop] = val + except Exception: + props[prop] = "" + +shape_info = None +if hasattr(obj, "Shape"): + shape = obj.Shape + shape_info = {{ + "type": shape.ShapeType, + "volume": shape.Volume if hasattr(shape, "Volume") else None, + "area": shape.Area if hasattr(shape, "Area") else None, + "is_valid": shape.isValid(), + }} + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "properties": props, + "shape_info": shape_info, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], +}} +""" + ) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to get object" + raise ValueError(error_msg) + + async def get_console_output( + self, + lines: int = 100, + ) -> list[str]: + """Get recent console output. + + Note: In embedded mode, we don't have direct access to FreeCAD's + console history, so we return an empty list or captured output. + """ + return [] + + async def get_freecad_version(self) -> dict[str, Any]: + """Get FreeCAD version information.""" + result = await self.execute_python( + """ +import sys +_result_ = { + "version": ".".join(str(x) for x in FreeCAD.Version()[:3]), + "version_tuple": FreeCAD.Version()[:3], + "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown", + "python_version": sys.version, + "gui_available": hasattr(FreeCAD, "GuiUp") and FreeCAD.GuiUp, +} +""" + ) + + if result.success and result.result: + return result.result + + return { + "version": "unknown", + "version_tuple": [], + "build_date": "unknown", + "python_version": sys.version, + "gui_available": False, + } + + async def is_gui_available(self) -> bool: + """Check if GUI is available.""" + result = await self.execute_python( + "_result_ = hasattr(FreeCAD, 'GuiUp') and FreeCAD.GuiUp" + ) + return bool(result.success and result.result) + + async def ping(self) -> float: + """Ping FreeCAD to check connection and measure latency. + + Returns: + Round-trip time in milliseconds. + + Raises: + ConnectionError: If not connected. + """ + if not self._connected: + msg = "Not connected to FreeCAD" + raise ConnectionError(msg) + + start = time.perf_counter() + result = await self.execute_python("_result_ = True") + elapsed = (time.perf_counter() - start) * 1000 + + if not result.success: + msg = "Ping failed" + raise ConnectionError(msg) + + return elapsed + + async def get_status(self) -> ConnectionStatus: + """Get detailed connection status. + + Returns: + ConnectionStatus with full status information. + """ + if not self._connected: + return ConnectionStatus( + connected=False, + mode="embedded", + error="Not connected", + ) + + try: + ping_ms = await self.ping() + version_info = await self.get_freecad_version() + gui_available = await self.is_gui_available() + + return ConnectionStatus( + connected=True, + mode="embedded", + freecad_version=version_info.get("version", "unknown"), + gui_available=gui_available, + last_ping_ms=ping_ms, + ) + except Exception as e: + return ConnectionStatus( + connected=False, + mode="embedded", + error=str(e), + ) + + # ========================================================================= + # Document Management + # ========================================================================= + + async def create_document( + self, name: str, label: str | None = None + ) -> DocumentInfo: + """Create a new document. + + Args: + name: Internal document name (no spaces). + label: Display label (optional, defaults to name). + + Returns: + DocumentInfo for the created document. + """ + label = label or name + result = await self.execute_python( + f""" +doc = FreeCAD.newDocument({name!r}) +doc.Label = {label!r} +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [], + "is_modified": False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create document" + raise ValueError(error_msg) + + async def open_document(self, path: str) -> DocumentInfo: + """Open an existing document. + + Args: + path: Path to the .FCStd file. + + Returns: + DocumentInfo for the opened document. + + Raises: + FileNotFoundError: If file doesn't exist. + ValueError: If file is not a valid FreeCAD document. + """ + if not Path(path).exists(): + msg = f"File not found: {path}" + raise FileNotFoundError(msg) + + result = await self.execute_python( + f""" +doc = FreeCAD.openDocument({path!r}) +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + error_msg = result.error_traceback or "Failed to open document" + raise ValueError(error_msg) + + async def save_document( + self, + doc_name: str | None = None, + path: str | None = None, + ) -> str: + """Save a document. + + Args: + doc_name: Document name (uses active if None). + path: Save path (uses existing path if None). + + Returns: + Path where document was saved. + + Raises: + ValueError: If document not found or no path specified for new doc. + """ + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}") + +save_path = {path!r} or doc.FileName +if not save_path: + raise ValueError("No path specified for new document") + +doc.saveAs(save_path) +_result_ = save_path +""" + + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + + error_msg = result.error_traceback or "Failed to save document" + raise ValueError(error_msg) + + async def close_document(self, doc_name: str | None = None) -> None: + """Close a document. + + Args: + doc_name: Document name (uses active if None). + """ + code = f""" +doc_name = {doc_name!r} +if doc_name is None: + doc = FreeCAD.ActiveDocument + if doc: + doc_name = doc.Name + else: + raise ValueError("No active document") + +FreeCAD.closeDocument(doc_name) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to close document" + raise ValueError(error_msg) + + # ========================================================================= + # Object Management + # ========================================================================= + + async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]: + """Get all objects in a document. + + Args: + doc_name: Document name (uses active if None). + + Returns: + List of ObjectInfo for each object. + """ + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = [] +for obj in doc.Objects: + obj_info = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], + }} + objects.append(obj_info) + +_result_ = objects +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [ObjectInfo(**obj) for obj in result.result] + return [] + + async def create_object( + self, + type_id: str, + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None, + ) -> ObjectInfo: + """Create a new object. + + Args: + type_id: FreeCAD type ID (e.g., "Part::Box", "Part::Cylinder"). + name: Object name (auto-generated if None). + properties: Initial property values. + doc_name: Target document (uses active if None). + + Returns: + ObjectInfo for the created object. + """ + properties = properties or {} + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.addObject({type_id!r}, {name!r} or "") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create object" + raise ValueError(error_msg) + + async def edit_object( + self, + obj_name: str, + properties: dict[str, Any], + doc_name: str | None = None, + ) -> ObjectInfo: + """Edit object properties. + + Args: + obj_name: Name of the object to edit. + properties: Property values to set. + doc_name: Document name (uses active if None). + + Returns: + Updated ObjectInfo. + + Raises: + ValueError: If object not found. + """ + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to edit object" + raise ValueError(error_msg) + + async def delete_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> None: + """Delete an object. + + Args: + obj_name: Name of the object to delete. + doc_name: Document name (uses active if None). + + Raises: + ValueError: If object not found. + """ + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +doc.removeObject({obj_name!r}) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to delete object" + raise ValueError(error_msg) + + # ========================================================================= + # View and Screenshot + # ========================================================================= + + async def get_screenshot( + self, + view_angle: ViewAngle | None = None, + width: int = 800, + height: int = 600, + doc_name: str | None = None, + ) -> ScreenshotResult: + """Capture a screenshot of the 3D view. + + Note: In embedded headless mode, screenshots are typically not available. + + Args: + view_angle: View angle to set before capture. + width: Image width in pixels. + height: Image height in pixels. + doc_name: Document name (uses active if None). + + Returns: + ScreenshotResult with image data or error. + """ + # Check if GUI is available + gui_available = await self.is_gui_available() + + if not gui_available: + return ScreenshotResult( + success=False, + error="Screenshots not available in headless mode", + width=width, + height=height, + view_angle=view_angle, + ) + + # If GUI is available, attempt screenshot + view_angle_str = view_angle.value if view_angle else "Isometric" + code = f""" +import base64 +import tempfile +import os + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +view = FreeCADGui.ActiveDocument.ActiveView +if view is None: + raise ValueError("No active view") + +# Set view angle +view_type = {view_angle_str!r} +if view_type == "FitAll": + view.fitAll() +elif view_type == "Isometric": + view.viewIsometric() +elif view_type == "Front": + view.viewFront() +elif view_type == "Back": + view.viewRear() +elif view_type == "Top": + view.viewTop() +elif view_type == "Bottom": + view.viewBottom() +elif view_type == "Left": + view.viewLeft() +elif view_type == "Right": + view.viewRight() + +# Save to temp file and read +with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + temp_path = f.name + +view.saveImage(temp_path, {width}, {height}, "Current") + +with open(temp_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode("utf-8") + +os.unlink(temp_path) + +_result_ = {{ + "success": True, + "data": image_data, + "format": "png", + "width": {width}, + "height": {height}, +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ScreenshotResult( + success=True, + data=result.result["data"], + format=result.result["format"], + width=result.result["width"], + height=result.result["height"], + view_angle=view_angle, + ) + + return ScreenshotResult( + success=False, + error=result.error_traceback or "Failed to capture screenshot", + width=width, + height=height, + view_angle=view_angle, + ) + + async def set_view( + self, + view_angle: ViewAngle, + doc_name: str | None = None, + ) -> None: + """Set the 3D view angle. + + Args: + view_angle: View angle to set. + doc_name: Document name (uses active if None). + """ + gui_available = await self.is_gui_available() + + if not gui_available: + return # Silently ignore in headless mode + + view_angle_str = view_angle.value + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +view = FreeCADGui.ActiveDocument.ActiveView +if view is None: + raise ValueError("No active view") + +view_type = {view_angle_str!r} +if view_type == "FitAll": + view.fitAll() +elif view_type == "Isometric": + view.viewIsometric() +elif view_type == "Front": + view.viewFront() +elif view_type == "Back": + view.viewRear() +elif view_type == "Top": + view.viewTop() +elif view_type == "Bottom": + view.viewBottom() +elif view_type == "Left": + view.viewLeft() +elif view_type == "Right": + view.viewRight() + +_result_ = True +""" + await self.execute_python(code) + + # ========================================================================= + # Macros + # ========================================================================= + + def _get_macro_path(self) -> Path: + """Get the FreeCAD macro directory path.""" + # Default FreeCAD macro locations + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "FreeCAD" / "Macro" + elif sys.platform == "win32": + return Path(os.environ.get("APPDATA", "")) / "FreeCAD" / "Macro" + else: + return Path.home() / ".local" / "share" / "FreeCAD" / "Macro" + + async def get_macros(self) -> list[MacroInfo]: + """Get list of available macros. + + Returns: + List of MacroInfo for each macro. + """ + macro_path = self._get_macro_path() + + if not macro_path.exists(): + return [] + + macros = [] + for macro_file in macro_path.glob("*.FCMacro"): + description = "" + try: + content = macro_file.read_text() + # Extract description from first comment block + for line in content.split("\n"): + if line.startswith("#"): + desc_line = line.lstrip("#").strip() + if desc_line and not desc_line.startswith("!"): + description = desc_line + break + except Exception: + pass + + macros.append( + MacroInfo( + name=macro_file.stem, + path=str(macro_file), + description=description, + is_system=False, + ) + ) + + return macros + + async def run_macro( + self, + macro_name: str, + args: dict[str, Any] | None = None, + ) -> ExecutionResult: + """Run a macro by name. + + Args: + macro_name: Macro name (without .FCMacro extension). + args: Arguments to pass to the macro. + + Returns: + ExecutionResult from macro execution. + """ + macro_path = self._get_macro_path() / f"{macro_name}.FCMacro" + + if not macro_path.exists(): + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=f"Macro not found: {macro_name}", + execution_time_ms=0, + error_type="FileNotFoundError", + ) + + try: + macro_code = macro_path.read_text() + except Exception as e: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=f"Failed to read macro: {e}", + execution_time_ms=0, + error_type=type(e).__name__, + ) + + # Prepend argument setup if provided + if args: + args_setup = "\n".join(f"{k} = {v!r}" for k, v in args.items()) + macro_code = args_setup + "\n" + macro_code + + return await self.execute_python(macro_code) + + async def create_macro( + self, + name: str, + code: str, + description: str = "", + ) -> MacroInfo: + """Create a new macro. + + Args: + name: Macro name (without extension). + code: Python code for the macro. + description: Macro description. + + Returns: + MacroInfo for the created macro. + """ + macro_path = self._get_macro_path() + macro_path.mkdir(parents=True, exist_ok=True) + + macro_file = macro_path / f"{name}.FCMacro" + + # Add description as header comment + header = f"# {description}\n\n" if description else "" + + # Add standard imports + full_code = f"""{header}# -*- coding: utf-8 -*- +# FreeCAD Macro: {name} +# Created via MCP Bridge + +import FreeCAD +import FreeCADGui + +{code} +""" + macro_file.write_text(full_code) + + return MacroInfo( + name=name, + path=str(macro_file), + description=description, + is_system=False, + ) + + # ========================================================================= + # Workbenches + # ========================================================================= + + async def get_workbenches(self) -> list[WorkbenchInfo]: + """Get list of available workbenches. + + Returns: + List of WorkbenchInfo for each workbench. + """ + gui_available = await self.is_gui_available() + + if not gui_available: + # Return common workbenches for headless mode + common_workbenches = [ + "StartWorkbench", + "PartWorkbench", + "PartDesignWorkbench", + "DraftWorkbench", + "SketcherWorkbench", + "MeshWorkbench", + "SpreadsheetWorkbench", + ] + return [ + WorkbenchInfo(name=wb, label=wb.replace("Workbench", "")) + for wb in common_workbenches + ] + + code = """ +workbenches = [] +active_wb = FreeCADGui.activeWorkbench() +active_name = active_wb.__class__.__name__ if active_wb else None + +for name in FreeCADGui.listWorkbenches(): + wb = FreeCADGui.getWorkbench(name) + workbenches.append({ + "name": name, + "label": wb.MenuText if hasattr(wb, "MenuText") else name, + "icon": wb.Icon if hasattr(wb, "Icon") else "", + "is_active": name == active_name, + }) + +_result_ = workbenches +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [WorkbenchInfo(**wb) for wb in result.result] + return [] + + async def activate_workbench(self, workbench_name: str) -> None: + """Activate a workbench. + + Args: + workbench_name: Workbench internal name. + + Raises: + ValueError: If workbench not found. + """ + gui_available = await self.is_gui_available() + + if not gui_available: + return # Silently ignore in headless mode + + code = f""" +try: + FreeCADGui.activateWorkbench({workbench_name!r}) + _result_ = True +except Exception as e: + raise ValueError(f"Failed to activate workbench: {{e}}") +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to activate workbench" + raise ValueError(error_msg) diff --git a/src/freecad_mcp/bridge/socket.py b/src/freecad_mcp/bridge/socket.py new file mode 100644 index 0000000..fd5e91f --- /dev/null +++ b/src/freecad_mcp/bridge/socket.py @@ -0,0 +1,1060 @@ +"""Socket bridge for FreeCAD communication via JSON-RPC. + +This bridge communicates with FreeCAD using JSON-RPC over TCP sockets, +providing a modern, lightweight alternative to XML-RPC. + +Based on learnings from competitive analysis: +- Simple socket-based approach (inspired by bonninr/freecad_mcp) +- Connection recovery mechanisms (from jango-blockchained) +- Automatic reconnection on connection loss +""" + +import asyncio +import contextlib +import json +import time +import uuid +from typing import Any + +from freecad_mcp.bridge.base import ( + ConnectionStatus, + DocumentInfo, + ExecutionResult, + FreecadBridge, + MacroInfo, + ObjectInfo, + ScreenshotResult, + ViewAngle, + WorkbenchInfo, +) + +DEFAULT_SOCKET_HOST = "localhost" +DEFAULT_SOCKET_PORT = 9876 +DEFAULT_TIMEOUT = 30.0 + + +class JsonRpcError(Exception): + """JSON-RPC error response.""" + + def __init__(self, code: int, message: str, data: Any = None) -> None: + """Initialize JSON-RPC error. + + Args: + code: Error code. + message: Error message. + data: Additional error data. + """ + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +class SocketBridge(FreecadBridge): + """Bridge that communicates with FreeCAD via JSON-RPC over sockets. + + This bridge provides a lightweight, modern protocol for FreeCAD + communication. It supports automatic reconnection and connection + health monitoring. + + Attributes: + host: Socket server hostname. + port: Socket server port. + timeout: Connection and request timeout in seconds. + """ + + def __init__( + self, + host: str = DEFAULT_SOCKET_HOST, + port: int = DEFAULT_SOCKET_PORT, + timeout: float = DEFAULT_TIMEOUT, + auto_reconnect: bool = True, + ) -> None: + """Initialize the socket bridge. + + Args: + host: Socket server hostname. + port: Socket server port. + timeout: Connection and request timeout in seconds. + auto_reconnect: Whether to automatically reconnect on connection loss. + """ + self._host = host + self._port = port + self._timeout = timeout + self._auto_reconnect = auto_reconnect + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._connected = False + self._lock = asyncio.Lock() + + async def connect(self) -> None: + """Establish connection to FreeCAD socket server. + + Raises: + ConnectionError: If connection cannot be established. + """ + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self._host, self._port), + timeout=self._timeout, + ) + self._connected = True + + # Verify connection with a ping + await self.ping() + except TimeoutError as e: + msg = f"Connection to {self._host}:{self._port} timed out" + raise ConnectionError(msg) from e + except Exception as e: + self._connected = False + msg = f"Failed to connect to {self._host}:{self._port}: {e}" + raise ConnectionError(msg) from e + + async def disconnect(self) -> None: + """Close connection to FreeCAD socket server.""" + if self._writer: + self._writer.close() + with contextlib.suppress(Exception): + await self._writer.wait_closed() + self._reader = None + self._writer = None + self._connected = False + + async def is_connected(self) -> bool: + """Check if bridge is connected to FreeCAD.""" + if not self._connected or self._writer is None: + return False + + try: + await self.ping() + return True + except Exception: + self._connected = False + return False + + async def _send_request( + self, + method: str, + params: dict[str, Any] | None = None, + ) -> Any: + """Send a JSON-RPC request and wait for response. + + Args: + method: Method name to call. + params: Method parameters. + + Returns: + Result from the JSON-RPC response. + + Raises: + ConnectionError: If not connected. + JsonRpcError: If server returns an error. + """ + if self._writer is None or self._reader is None: + msg = "Not connected to socket server" + raise ConnectionError(msg) + + # Build JSON-RPC request + request_id = str(uuid.uuid4()) + request = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params or {}, + } + + async with self._lock: + try: + # Send request + request_data = json.dumps(request).encode("utf-8") + b"\n" + self._writer.write(request_data) + await self._writer.drain() + + # Read response + response_data = await asyncio.wait_for( + self._reader.readline(), + timeout=self._timeout, + ) + + if not response_data: + self._connected = False + msg = "Connection closed by server" + raise ConnectionError(msg) + + response = json.loads(response_data.decode("utf-8")) + + # Check for error + if "error" in response: + error = response["error"] + raise JsonRpcError( + code=error.get("code", -1), + message=error.get("message", "Unknown error"), + data=error.get("data"), + ) + + return response.get("result") + + except TimeoutError as e: + msg = "Request timed out" + raise ConnectionError(msg) from e + except json.JSONDecodeError as e: + msg = f"Invalid JSON response: {e}" + raise ConnectionError(msg) from e + except (ConnectionResetError, BrokenPipeError) as e: + self._connected = False + if self._auto_reconnect: + # Try to reconnect + try: + await self.connect() + return await self._send_request(method, params) + except Exception: + pass + msg = f"Connection lost: {e}" + raise ConnectionError(msg) from e + + async def ping(self) -> float: + """Ping FreeCAD to check connection and measure latency. + + Returns: + Round-trip time in milliseconds. + + Raises: + ConnectionError: If not connected. + """ + start = time.perf_counter() + await self._send_request("ping") + return (time.perf_counter() - start) * 1000 + + async def get_status(self) -> ConnectionStatus: + """Get detailed connection status. + + Returns: + ConnectionStatus with full status information. + """ + if not self._connected: + return ConnectionStatus( + connected=False, + mode="socket", + error="Not connected", + ) + + try: + ping_ms = await self.ping() + version_info = await self.get_freecad_version() + gui_available = await self.is_gui_available() + + return ConnectionStatus( + connected=True, + mode="socket", + freecad_version=version_info.get("version", "unknown"), + gui_available=gui_available, + last_ping_ms=ping_ms, + ) + except Exception as e: + return ConnectionStatus( + connected=False, + mode="socket", + error=str(e), + ) + + # ========================================================================= + # Code Execution + # ========================================================================= + + async def execute_python( + self, + code: str, + timeout_ms: int = 30000, + ) -> ExecutionResult: + """Execute Python code in FreeCAD context via socket. + + Args: + code: Python code to execute. + timeout_ms: Maximum execution time in milliseconds. + + Returns: + ExecutionResult with execution outcome. + """ + start = time.perf_counter() + + try: + result = await asyncio.wait_for( + self._send_request("execute", {"code": code}), + timeout=timeout_ms / 1000, + ) + elapsed = (time.perf_counter() - start) * 1000 + + if isinstance(result, dict): + return ExecutionResult( + success=result.get("success", False), + result=result.get("result"), + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + execution_time_ms=elapsed, + error_type=result.get("error_type"), + error_traceback=result.get("error_traceback"), + ) + else: + return ExecutionResult( + success=True, + result=result, + stdout="", + stderr="", + execution_time_ms=elapsed, + ) + + except TimeoutError: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=f"Execution timed out after {timeout_ms}ms", + execution_time_ms=float(timeout_ms), + error_type="TimeoutError", + ) + except JsonRpcError as e: + elapsed = (time.perf_counter() - start) * 1000 + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=e.message, + execution_time_ms=elapsed, + error_type="JsonRpcError", + ) + except ConnectionError as e: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=str(e), + execution_time_ms=0, + error_type="ConnectionError", + ) + + # ========================================================================= + # Document Management + # ========================================================================= + + async def get_documents(self) -> list[DocumentInfo]: + """Get list of open documents.""" + result = await self.execute_python( + """ +_result_ = [] +for doc in FreeCAD.listDocuments().values(): + _result_.append({ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "active_object": doc.ActiveObject.Name if doc.ActiveObject else None, + }) +""" + ) + + if result.success and result.result: + return [DocumentInfo(**doc) for doc in result.result] + return [] + + async def get_active_document(self) -> DocumentInfo | None: + """Get the active document.""" + result = await self.execute_python( + """ +doc = FreeCAD.ActiveDocument +if doc: + _result_ = { + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "active_object": doc.ActiveObject.Name if doc.ActiveObject else None, + } +else: + _result_ = None +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + return None + + async def create_document( + self, name: str, label: str | None = None + ) -> DocumentInfo: + """Create a new document.""" + label = label or name + result = await self.execute_python( + f""" +doc = FreeCAD.newDocument({name!r}) +doc.Label = {label!r} +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [], + "is_modified": False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create document" + raise ValueError(error_msg) + + async def open_document(self, path: str) -> DocumentInfo: + """Open an existing document.""" + result = await self.execute_python( + f""" +import os +if not os.path.exists({path!r}): + raise FileNotFoundError(f"File not found: {path!r}") + +doc = FreeCAD.openDocument({path!r}) +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + if "FileNotFoundError" in (result.error_type or ""): + raise FileNotFoundError(result.stderr) + + error_msg = result.error_traceback or "Failed to open document" + raise ValueError(error_msg) + + async def save_document( + self, + doc_name: str | None = None, + path: str | None = None, + ) -> str: + """Save a document.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}") + +save_path = {path!r} or doc.FileName +if not save_path: + raise ValueError("No path specified for new document") + +doc.saveAs(save_path) +_result_ = save_path +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + + error_msg = result.error_traceback or "Failed to save document" + raise ValueError(error_msg) + + async def close_document(self, doc_name: str | None = None) -> None: + """Close a document.""" + code = f""" +doc_name = {doc_name!r} +if doc_name is None: + doc = FreeCAD.ActiveDocument + if doc: + doc_name = doc.Name + else: + raise ValueError("No active document") + +FreeCAD.closeDocument(doc_name) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to close document" + raise ValueError(error_msg) + + # ========================================================================= + # Object Management + # ========================================================================= + + async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]: + """Get all objects in a document.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = [] +for obj in doc.Objects: + obj_info = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], + }} + objects.append(obj_info) + +_result_ = objects +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [ObjectInfo(**obj) for obj in result.result] + return [] + + async def get_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> ObjectInfo: + """Get detailed object information.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +props = {{}} +for prop in obj.PropertiesList: + try: + val = getattr(obj, prop) + if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins': + val = str(val) + props[prop] = val + except Exception: + props[prop] = "" + +shape_info = None +if hasattr(obj, "Shape"): + shape = obj.Shape + shape_info = {{ + "shape_type": shape.ShapeType, + "volume": shape.Volume if hasattr(shape, "Volume") else None, + "area": shape.Area if hasattr(shape, "Area") else None, + "is_valid": shape.isValid(), + "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False, + "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0, + "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0, + "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0, + }} + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "properties": props, + "shape_info": shape_info, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to get object" + raise ValueError(error_msg) + + async def create_object( + self, + type_id: str, + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None, + ) -> ObjectInfo: + """Create a new object.""" + properties = properties or {} + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.addObject({type_id!r}, {name!r} or "") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create object" + raise ValueError(error_msg) + + async def edit_object( + self, + obj_name: str, + properties: dict[str, Any], + doc_name: str | None = None, + ) -> ObjectInfo: + """Edit object properties.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to edit object" + raise ValueError(error_msg) + + async def delete_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> None: + """Delete an object.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +doc.removeObject({obj_name!r}) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to delete object" + raise ValueError(error_msg) + + # ========================================================================= + # View and Screenshot + # ========================================================================= + + async def get_screenshot( + self, + view_angle: ViewAngle | None = None, + width: int = 800, + height: int = 600, + doc_name: str | None = None, + ) -> ScreenshotResult: + """Capture a screenshot of the 3D view via socket.""" + view_angle_str = view_angle.value if view_angle else "Isometric" + code = f""" +import base64 +import tempfile +import os + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +# Check if GUI is available +if not FreeCAD.GuiUp: + raise RuntimeError("GUI not available") + +view = FreeCADGui.ActiveDocument.ActiveView +if view is None: + raise ValueError("No active view") + +# Check view type +view_type = view.__class__.__name__ +if view_type not in ["View3DInventor", "View3DInventorPy"]: + raise ValueError(f"Cannot capture screenshot from {{view_type}} view") + +# Set view angle +view_type_str = {view_angle_str!r} +if view_type_str == "FitAll": + view.fitAll() +elif view_type_str == "Isometric": + view.viewIsometric() +elif view_type_str == "Front": + view.viewFront() +elif view_type_str == "Back": + view.viewRear() +elif view_type_str == "Top": + view.viewTop() +elif view_type_str == "Bottom": + view.viewBottom() +elif view_type_str == "Left": + view.viewLeft() +elif view_type_str == "Right": + view.viewRight() + +# Save to temp file and read +with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + temp_path = f.name + +view.saveImage(temp_path, {width}, {height}, "Current") + +with open(temp_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode("utf-8") + +os.unlink(temp_path) + +_result_ = {{ + "success": True, + "data": image_data, + "format": "png", + "width": {width}, + "height": {height}, +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ScreenshotResult( + success=True, + data=result.result["data"], + format=result.result["format"], + width=result.result["width"], + height=result.result["height"], + view_angle=view_angle, + ) + + return ScreenshotResult( + success=False, + error=result.error_traceback + or result.stderr + or "Failed to capture screenshot", + width=width, + height=height, + view_angle=view_angle, + ) + + async def set_view( + self, + view_angle: ViewAngle, + doc_name: str | None = None, + ) -> None: + """Set the 3D view angle.""" + view_angle_str = view_angle.value + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +if not FreeCAD.GuiUp: + _result_ = True +else: + view = FreeCADGui.ActiveDocument.ActiveView + if view is None: + raise ValueError("No active view") + + view_type = {view_angle_str!r} + if view_type == "FitAll": + view.fitAll() + elif view_type == "Isometric": + view.viewIsometric() + elif view_type == "Front": + view.viewFront() + elif view_type == "Back": + view.viewRear() + elif view_type == "Top": + view.viewTop() + elif view_type == "Bottom": + view.viewBottom() + elif view_type == "Left": + view.viewLeft() + elif view_type == "Right": + view.viewRight() + + _result_ = True +""" + await self.execute_python(code) + + # ========================================================================= + # Macros + # ========================================================================= + + async def get_macros(self) -> list[MacroInfo]: + """Get list of available macros.""" + code = """ +import os + +macro_paths = [] + +user_path = FreeCAD.getUserMacroDir(True) +if os.path.exists(user_path): + macro_paths.append(("user", user_path)) + +system_path = FreeCAD.getResourceDir() + "Macro" +if os.path.exists(system_path): + macro_paths.append(("system", system_path)) + +macros = [] +for source, path in macro_paths: + for filename in os.listdir(path): + if filename.endswith(".FCMacro"): + macro_file = os.path.join(path, filename) + description = "" + try: + with open(macro_file, "r") as f: + for line in f: + if line.startswith("#"): + desc = line.lstrip("#").strip() + if desc and not desc.startswith("!") and not desc.startswith("-*-"): + description = desc + break + except Exception: + pass + + macros.append({ + "name": filename[:-8], + "path": macro_file, + "description": description, + "is_system": source == "system", + }) + +_result_ = macros +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [MacroInfo(**m) for m in result.result] + return [] + + async def run_macro( + self, + macro_name: str, + args: dict[str, Any] | None = None, + ) -> ExecutionResult: + """Run a macro by name.""" + args = args or {} + code = f""" +import os + +macro_name = {macro_name!r} +macro_file = None + +user_path = FreeCAD.getUserMacroDir(True) +user_macro = os.path.join(user_path, macro_name + ".FCMacro") +if os.path.exists(user_macro): + macro_file = user_macro + +if not macro_file: + system_path = FreeCAD.getResourceDir() + "Macro" + system_macro = os.path.join(system_path, macro_name + ".FCMacro") + if os.path.exists(system_macro): + macro_file = system_macro + +if not macro_file: + raise FileNotFoundError(f"Macro not found: {{macro_name}}") + +args = {args!r} +for k, v in args.items(): + exec(f"{{k}} = {{v!r}}") + +with open(macro_file, "r") as f: + macro_code = f.read() + +exec(macro_code) +_result_ = True +""" + return await self.execute_python(code) + + async def create_macro( + self, + name: str, + code: str, + description: str = "", + ) -> MacroInfo: + """Create a new macro.""" + create_code = f""" +import os + +macro_path = FreeCAD.getUserMacroDir(True) +os.makedirs(macro_path, exist_ok=True) + +macro_file = os.path.join(macro_path, {name!r} + ".FCMacro") + +header = "" +if {description!r}: + header = f"# {description!r}\\n\\n" + +full_code = header + '''# -*- coding: utf-8 -*- +# FreeCAD Macro: {name} +# Created via MCP Bridge + +import FreeCAD +import FreeCADGui + +''' + {code!r} + +with open(macro_file, "w") as f: + f.write(full_code) + +_result_ = {{ + "name": {name!r}, + "path": macro_file, + "description": {description!r}, + "is_system": False, +}} +""" + result = await self.execute_python(create_code) + + if result.success and result.result: + return MacroInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create macro" + raise ValueError(error_msg) + + # ========================================================================= + # Workbenches + # ========================================================================= + + async def get_workbenches(self) -> list[WorkbenchInfo]: + """Get list of available workbenches.""" + code = """ +workbenches = [] +active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None +active_name = active_wb.__class__.__name__ if active_wb else None + +if FreeCAD.GuiUp: + for name in FreeCADGui.listWorkbenches(): + wb = FreeCADGui.getWorkbench(name) + workbenches.append({ + "name": name, + "label": wb.MenuText if hasattr(wb, "MenuText") else name, + "icon": wb.Icon if hasattr(wb, "Icon") else "", + "is_active": name == active_name, + }) +else: + common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench", + "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"] + for name in common: + workbenches.append({ + "name": name, + "label": name.replace("Workbench", ""), + "icon": "", + "is_active": False, + }) + +_result_ = workbenches +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [WorkbenchInfo(**wb) for wb in result.result] + return [] + + async def activate_workbench(self, workbench_name: str) -> None: + """Activate a workbench.""" + code = f""" +if FreeCAD.GuiUp: + try: + FreeCADGui.activateWorkbench({workbench_name!r}) + _result_ = True + except Exception as e: + raise ValueError(f"Failed to activate workbench: {{e}}") +else: + _result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to activate workbench" + raise ValueError(error_msg) + + # ========================================================================= + # Version and Environment + # ========================================================================= + + async def get_freecad_version(self) -> dict[str, Any]: + """Get FreeCAD version information.""" + code = """ +import sys +_result_ = { + "version": ".".join(str(x) for x in FreeCAD.Version()[:3]), + "version_tuple": FreeCAD.Version()[:3], + "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown", + "python_version": sys.version, + "gui_available": FreeCAD.GuiUp, +} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + + return { + "version": "unknown", + "version_tuple": [], + "build_date": "unknown", + "python_version": "", + "gui_available": False, + } + + async def is_gui_available(self) -> bool: + """Check if FreeCAD GUI is available.""" + result = await self.execute_python("_result_ = FreeCAD.GuiUp") + return bool(result.success and result.result) + + # ========================================================================= + # Console + # ========================================================================= + + async def get_console_output(self, lines: int = 100) -> list[str]: + """Get recent console output.""" + code = f""" +output_lines = [] + +if hasattr(FreeCAD, 'Console'): + console = FreeCAD.Console + if hasattr(console, 'GetLog'): + log = console.GetLog() + if log: + output_lines = log.split('\\n')[-{lines}:] + +_result_ = output_lines +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + return [] diff --git a/src/freecad_mcp/bridge/xmlrpc.py b/src/freecad_mcp/bridge/xmlrpc.py new file mode 100644 index 0000000..0ed5778 --- /dev/null +++ b/src/freecad_mcp/bridge/xmlrpc.py @@ -0,0 +1,1049 @@ +"""XML-RPC bridge for FreeCAD GUI mode communication. + +This bridge connects to a FreeCAD instance running an XML-RPC server, +allowing remote control while FreeCAD has its GUI open. + +Compatible with neka-nat/freecad-mcp XML-RPC protocol, providing +interoperability with existing FreeCAD MCP addons. + +Design inspired by neka-nat/freecad-mcp (MIT License): +- Uses neka-nat's proven XML-RPC protocol (port 9875) +- Connection health monitoring and auto-reconnect +- Timeout handling for long operations + +Attribution: + The XML-RPC protocol design and port selection (9875) were inspired by + neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp), which is + licensed under the MIT License. This implementation is a complete rewrite + that maintains protocol compatibility. +""" + +import asyncio +import time +import xmlrpc.client +from typing import Any + +from freecad_mcp.bridge.base import ( + ConnectionStatus, + DocumentInfo, + ExecutionResult, + FreecadBridge, + MacroInfo, + ObjectInfo, + ScreenshotResult, + ViewAngle, + WorkbenchInfo, +) + +DEFAULT_XMLRPC_HOST = "localhost" +DEFAULT_XMLRPC_PORT = 9875 +DEFAULT_TIMEOUT = 30.0 + + +class XmlRpcBridge(FreecadBridge): + """Bridge that communicates with FreeCAD via XML-RPC. + + This bridge connects to a FreeCAD instance that is running the + XML-RPC server addon. It provides full access to FreeCAD functionality + including GUI operations like screenshots. + + Attributes: + host: XML-RPC server hostname. + port: XML-RPC server port. + timeout: Connection and request timeout in seconds. + """ + + def __init__( + self, + host: str = DEFAULT_XMLRPC_HOST, + port: int = DEFAULT_XMLRPC_PORT, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + """Initialize the XML-RPC bridge. + + Args: + host: XML-RPC server hostname. + port: XML-RPC server port. + timeout: Connection and request timeout in seconds. + """ + self._host = host + self._port = port + self._timeout = timeout + self._proxy: xmlrpc.client.ServerProxy | None = None + self._connected = False + + @property + def _server_url(self) -> str: + """Get the XML-RPC server URL.""" + return f"http://{self._host}:{self._port}" + + async def connect(self) -> None: + """Establish connection to FreeCAD XML-RPC server. + + Raises: + ConnectionError: If connection cannot be established. + """ + loop = asyncio.get_event_loop() + try: + self._proxy = await loop.run_in_executor( + None, + lambda: xmlrpc.client.ServerProxy( + self._server_url, + allow_none=True, + ), + ) + # Test connection with a ping + await self.ping() + self._connected = True + except ConnectionRefusedError as e: + self._connected = False + msg = self._get_connection_refused_message() + raise ConnectionError(msg) from e + except Exception as e: + self._connected = False + error_str = str(e) + if "Connection refused" in error_str or "Errno 61" in error_str: + msg = self._get_connection_refused_message() + else: + msg = f"Failed to connect to XML-RPC server at {self._server_url}: {e}" + raise ConnectionError(msg) from e + + def _get_connection_refused_message(self) -> str: + """Get a helpful error message when connection is refused.""" + return f""" +================================================================================ +CONNECTION REFUSED: Cannot connect to FreeCAD at {self._server_url} + +The FreeCAD MCP bridge server is not running. To fix this: + +1. Start FreeCAD (the GUI application) + +2. In FreeCAD's Python console (View → Panels → Python console), run: + + import sys + sys.path.insert(0, "/path/to/freecad-mcp/src") + from freecad_mcp.freecad_plugin.server import FreecadMCPPlugin + plugin = FreecadMCPPlugin() + plugin.start() + + Or run the StartMCPBridge macro if installed: + - Macro → Macros → StartMCPBridge → Execute + +3. You should see: "MCP Bridge started!" + - XML-RPC: localhost:{self._port} + - Socket: localhost:9876 + +4. Then restart your MCP client (e.g., restart Claude Code) + +To install the macro automatically, run from the project directory: + just install-macro +================================================================================ +""" + + async def disconnect(self) -> None: + """Close connection to FreeCAD XML-RPC server.""" + self._proxy = None + self._connected = False + + async def is_connected(self) -> bool: + """Check if bridge is connected to FreeCAD.""" + if not self._connected or self._proxy is None: + return False + + try: + await self.ping() + return True + except Exception: + self._connected = False + return False + + async def ping(self) -> float: + """Ping FreeCAD to check connection and measure latency. + + Returns: + Round-trip time in milliseconds. + + Raises: + ConnectionError: If not connected. + """ + if self._proxy is None: + msg = "Not connected to XML-RPC server" + raise ConnectionError(msg) + + loop = asyncio.get_event_loop() + start = time.perf_counter() + + try: + # Try standard system.listMethods or a simple execute + if self._proxy is None: + msg = "Not connected" + raise ConnectionError(msg) + proxy = self._proxy # Local reference for lambda + await asyncio.wait_for( + loop.run_in_executor( + None, + lambda: proxy.execute("_result_ = True"), + ), + timeout=self._timeout, + ) + except TimeoutError as e: + msg = "Ping timed out" + raise ConnectionError(msg) from e + except Exception as e: + msg = f"Ping failed: {e}" + raise ConnectionError(msg) from e + + return (time.perf_counter() - start) * 1000 + + async def get_status(self) -> ConnectionStatus: + """Get detailed connection status. + + Returns: + ConnectionStatus with full status information. + """ + if not self._connected or self._proxy is None: + return ConnectionStatus( + connected=False, + mode="xmlrpc", + error="Not connected", + ) + + try: + ping_ms = await self.ping() + version_info = await self.get_freecad_version() + gui_available = await self.is_gui_available() + + return ConnectionStatus( + connected=True, + mode="xmlrpc", + freecad_version=version_info.get("version", "unknown"), + gui_available=gui_available, + last_ping_ms=ping_ms, + ) + except Exception as e: + return ConnectionStatus( + connected=False, + mode="xmlrpc", + error=str(e), + ) + + # ========================================================================= + # Code Execution + # ========================================================================= + + async def execute_python( + self, + code: str, + timeout_ms: int = 30000, + ) -> ExecutionResult: + """Execute Python code in FreeCAD context via XML-RPC. + + Args: + code: Python code to execute. + timeout_ms: Maximum execution time in milliseconds. + + Returns: + ExecutionResult with execution outcome. + """ + if self._proxy is None: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr="Not connected to XML-RPC server", + execution_time_ms=0, + error_type="ConnectionError", + ) + + loop = asyncio.get_event_loop() + start = time.perf_counter() + proxy = self._proxy # Local reference for lambda + + try: + result = await asyncio.wait_for( + loop.run_in_executor( + None, + lambda: proxy.execute(code), + ), + timeout=timeout_ms / 1000, + ) + elapsed = (time.perf_counter() - start) * 1000 + + # Parse result from XML-RPC server + if isinstance(result, dict): + return ExecutionResult( + success=result.get("success", False), + result=result.get("result"), + stdout=result.get("stdout", ""), + stderr=result.get("stderr", ""), + execution_time_ms=elapsed, + error_type=result.get("error_type"), + error_traceback=result.get("error_traceback"), + ) + else: + # Simple result format + return ExecutionResult( + success=True, + result=result, + stdout="", + stderr="", + execution_time_ms=elapsed, + ) + + except TimeoutError: + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=f"Execution timed out after {timeout_ms}ms", + execution_time_ms=float(timeout_ms), + error_type="TimeoutError", + ) + except Exception as e: + elapsed = (time.perf_counter() - start) * 1000 + return ExecutionResult( + success=False, + result=None, + stdout="", + stderr=str(e), + execution_time_ms=elapsed, + error_type=type(e).__name__, + ) + + # ========================================================================= + # Document Management + # ========================================================================= + + async def get_documents(self) -> list[DocumentInfo]: + """Get list of open documents.""" + result = await self.execute_python( + """ +_result_ = [] +for doc in FreeCAD.listDocuments().values(): + _result_.append({ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "active_object": doc.ActiveObject.Name if doc.ActiveObject else None, + }) +""" + ) + + if result.success and result.result: + return [DocumentInfo(**doc) for doc in result.result] + return [] + + async def get_active_document(self) -> DocumentInfo | None: + """Get the active document.""" + result = await self.execute_python( + """ +doc = FreeCAD.ActiveDocument +if doc: + _result_ = { + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, + "active_object": doc.ActiveObject.Name if doc.ActiveObject else None, + } +else: + _result_ = None +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + return None + + async def create_document( + self, name: str, label: str | None = None + ) -> DocumentInfo: + """Create a new document.""" + label = label or name + result = await self.execute_python( + f""" +doc = FreeCAD.newDocument({name!r}) +doc.Label = {label!r} +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [], + "is_modified": False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create document" + raise ValueError(error_msg) + + async def open_document(self, path: str) -> DocumentInfo: + """Open an existing document.""" + result = await self.execute_python( + f""" +import os +if not os.path.exists({path!r}): + raise FileNotFoundError(f"File not found: {path!r}") + +doc = FreeCAD.openDocument({path!r}) +_result_ = {{ + "name": doc.Name, + "label": doc.Label, + "path": doc.FileName or None, + "objects": [obj.Name for obj in doc.Objects], + "is_modified": doc.Modified if hasattr(doc, "Modified") else False, +}} +""" + ) + + if result.success and result.result: + return DocumentInfo(**result.result) + + if "FileNotFoundError" in (result.error_type or ""): + raise FileNotFoundError(result.stderr) + + error_msg = result.error_traceback or "Failed to open document" + raise ValueError(error_msg) + + async def save_document( + self, + doc_name: str | None = None, + path: str | None = None, + ) -> str: + """Save a document.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No active document" if {doc_name!r} is None else f"Document not found: {doc_name!r}") + +save_path = {path!r} or doc.FileName +if not save_path: + raise ValueError("No path specified for new document") + +doc.saveAs(save_path) +_result_ = save_path +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + + error_msg = result.error_traceback or "Failed to save document" + raise ValueError(error_msg) + + async def close_document(self, doc_name: str | None = None) -> None: + """Close a document.""" + code = f""" +doc_name = {doc_name!r} +if doc_name is None: + doc = FreeCAD.ActiveDocument + if doc: + doc_name = doc.Name + else: + raise ValueError("No active document") + +FreeCAD.closeDocument(doc_name) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to close document" + raise ValueError(error_msg) + + # ========================================================================= + # Object Management + # ========================================================================= + + async def get_objects(self, doc_name: str | None = None) -> list[ObjectInfo]: + """Get all objects in a document.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = [] +for obj in doc.Objects: + obj_info = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], + }} + objects.append(obj_info) + +_result_ = objects +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [ObjectInfo(**obj) for obj in result.result] + return [] + + async def get_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> ObjectInfo: + """Get detailed object information.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +props = {{}} +for prop in obj.PropertiesList: + try: + val = getattr(obj, prop) + if hasattr(val, '__class__') and val.__class__.__module__ != 'builtins': + val = str(val) + props[prop] = val + except Exception: + props[prop] = "" + +shape_info = None +if hasattr(obj, "Shape"): + shape = obj.Shape + shape_info = {{ + "shape_type": shape.ShapeType, + "volume": shape.Volume if hasattr(shape, "Volume") else None, + "area": shape.Area if hasattr(shape, "Area") else None, + "is_valid": shape.isValid(), + "is_closed": shape.isClosed() if hasattr(shape, "isClosed") else False, + "vertex_count": len(shape.Vertexes) if hasattr(shape, "Vertexes") else 0, + "edge_count": len(shape.Edges) if hasattr(shape, "Edges") else 0, + "face_count": len(shape.Faces) if hasattr(shape, "Faces") else 0, + }} + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "properties": props, + "shape_info": shape_info, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to get object" + raise ValueError(error_msg) + + async def create_object( + self, + type_id: str, + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None, + ) -> ObjectInfo: + """Create a new object.""" + properties = properties or {} + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.addObject({type_id!r}, {name!r} or "") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create object" + raise ValueError(error_msg) + + async def edit_object( + self, + obj_name: str, + properties: dict[str, Any], + doc_name: str | None = None, + ) -> ObjectInfo: + """Edit object properties.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +# Set properties +for prop_name, prop_val in {properties!r}.items(): + if hasattr(obj, prop_name): + setattr(obj, prop_name, prop_val) + +doc.recompute() + +_result_ = {{ + "name": obj.Name, + "label": obj.Label, + "type_id": obj.TypeId, + "visibility": obj.ViewObject.Visibility if hasattr(obj, "ViewObject") and obj.ViewObject else True, + "children": [c.Name for c in obj.OutList] if hasattr(obj, "OutList") else [], + "parents": [p.Name for p in obj.InList] if hasattr(obj, "InList") else [], +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ObjectInfo(**result.result) + + error_msg = result.error_traceback or "Failed to edit object" + raise ValueError(error_msg) + + async def delete_object( + self, + obj_name: str, + doc_name: str | None = None, + ) -> None: + """Delete an object.""" + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({obj_name!r}) +if obj is None: + raise ValueError(f"Object not found: {obj_name!r}") + +doc.removeObject({obj_name!r}) +_result_ = True +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to delete object" + raise ValueError(error_msg) + + # ========================================================================= + # View and Screenshot + # ========================================================================= + + async def get_screenshot( + self, + view_angle: ViewAngle | None = None, + width: int = 800, + height: int = 600, + doc_name: str | None = None, + ) -> ScreenshotResult: + """Capture a screenshot of the 3D view via XML-RPC.""" + view_angle_str = view_angle.value if view_angle else "Isometric" + code = f""" +import base64 +import tempfile +import os + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +# Check if GUI is available +if not FreeCAD.GuiUp: + raise RuntimeError("GUI not available") + +view = FreeCADGui.ActiveDocument.ActiveView +if view is None: + raise ValueError("No active view") + +# Check if this is a 3D view (not TechDraw, Spreadsheet, etc.) +view_type = view.__class__.__name__ +if view_type not in ["View3DInventor", "View3DInventorPy"]: + raise ValueError(f"Cannot capture screenshot from {{view_type}} view") + +# Set view angle +view_type_str = {view_angle_str!r} +if view_type_str == "FitAll": + view.fitAll() +elif view_type_str == "Isometric": + view.viewIsometric() +elif view_type_str == "Front": + view.viewFront() +elif view_type_str == "Back": + view.viewRear() +elif view_type_str == "Top": + view.viewTop() +elif view_type_str == "Bottom": + view.viewBottom() +elif view_type_str == "Left": + view.viewLeft() +elif view_type_str == "Right": + view.viewRight() + +# Save to temp file and read +with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + temp_path = f.name + +view.saveImage(temp_path, {width}, {height}, "Current") + +with open(temp_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode("utf-8") + +os.unlink(temp_path) + +_result_ = {{ + "success": True, + "data": image_data, + "format": "png", + "width": {width}, + "height": {height}, +}} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return ScreenshotResult( + success=True, + data=result.result["data"], + format=result.result["format"], + width=result.result["width"], + height=result.result["height"], + view_angle=view_angle, + ) + + return ScreenshotResult( + success=False, + error=result.error_traceback + or result.stderr + or "Failed to capture screenshot", + width=width, + height=height, + view_angle=view_angle, + ) + + async def set_view( + self, + view_angle: ViewAngle, + doc_name: str | None = None, + ) -> None: + """Set the 3D view angle.""" + view_angle_str = view_angle.value + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +if not FreeCAD.GuiUp: + _result_ = True # Silently succeed in headless mode +else: + view = FreeCADGui.ActiveDocument.ActiveView + if view is None: + raise ValueError("No active view") + + view_type = {view_angle_str!r} + if view_type == "FitAll": + view.fitAll() + elif view_type == "Isometric": + view.viewIsometric() + elif view_type == "Front": + view.viewFront() + elif view_type == "Back": + view.viewRear() + elif view_type == "Top": + view.viewTop() + elif view_type == "Bottom": + view.viewBottom() + elif view_type == "Left": + view.viewLeft() + elif view_type == "Right": + view.viewRight() + + _result_ = True +""" + await self.execute_python(code) + + # ========================================================================= + # Macros + # ========================================================================= + + async def get_macros(self) -> list[MacroInfo]: + """Get list of available macros.""" + code = """ +import os + +# Get macro paths +macro_paths = [] + +# User macro path +user_path = FreeCAD.getUserMacroDir(True) +if os.path.exists(user_path): + macro_paths.append(("user", user_path)) + +# System macro path +system_path = FreeCAD.getResourceDir() + "Macro" +if os.path.exists(system_path): + macro_paths.append(("system", system_path)) + +macros = [] +for source, path in macro_paths: + for filename in os.listdir(path): + if filename.endswith(".FCMacro"): + macro_file = os.path.join(path, filename) + description = "" + try: + with open(macro_file, "r") as f: + for line in f: + if line.startswith("#"): + desc = line.lstrip("#").strip() + if desc and not desc.startswith("!") and not desc.startswith("-*-"): + description = desc + break + except Exception: + pass + + macros.append({ + "name": filename[:-8], # Remove .FCMacro + "path": macro_file, + "description": description, + "is_system": source == "system", + }) + +_result_ = macros +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [MacroInfo(**m) for m in result.result] + return [] + + async def run_macro( + self, + macro_name: str, + args: dict[str, Any] | None = None, + ) -> ExecutionResult: + """Run a macro by name.""" + args = args or {} + code = f""" +import os + +# Find macro file +macro_name = {macro_name!r} +macro_file = None + +# Check user macros first +user_path = FreeCAD.getUserMacroDir(True) +user_macro = os.path.join(user_path, macro_name + ".FCMacro") +if os.path.exists(user_macro): + macro_file = user_macro + +# Check system macros +if not macro_file: + system_path = FreeCAD.getResourceDir() + "Macro" + system_macro = os.path.join(system_path, macro_name + ".FCMacro") + if os.path.exists(system_macro): + macro_file = system_macro + +if not macro_file: + raise FileNotFoundError(f"Macro not found: {{macro_name}}") + +# Set up arguments +args = {args!r} +for k, v in args.items(): + exec(f"{{k}} = {{v!r}}") + +# Execute macro +with open(macro_file, "r") as f: + macro_code = f.read() + +exec(macro_code) +_result_ = True +""" + return await self.execute_python(code) + + async def create_macro( + self, + name: str, + code: str, + description: str = "", + ) -> MacroInfo: + """Create a new macro.""" + create_code = f""" +import os + +macro_path = FreeCAD.getUserMacroDir(True) +os.makedirs(macro_path, exist_ok=True) + +macro_file = os.path.join(macro_path, {name!r} + ".FCMacro") + +header = "" +if {description!r}: + header = f"# {description!r}\\n\\n" + +full_code = header + '''# -*- coding: utf-8 -*- +# FreeCAD Macro: {name} +# Created via MCP Bridge + +import FreeCAD +import FreeCADGui + +''' + {code!r} + +with open(macro_file, "w") as f: + f.write(full_code) + +_result_ = {{ + "name": {name!r}, + "path": macro_file, + "description": {description!r}, + "is_system": False, +}} +""" + result = await self.execute_python(create_code) + + if result.success and result.result: + return MacroInfo(**result.result) + + error_msg = result.error_traceback or "Failed to create macro" + raise ValueError(error_msg) + + # ========================================================================= + # Workbenches + # ========================================================================= + + async def get_workbenches(self) -> list[WorkbenchInfo]: + """Get list of available workbenches.""" + code = """ +workbenches = [] +active_wb = FreeCADGui.activeWorkbench() if FreeCAD.GuiUp else None +active_name = active_wb.__class__.__name__ if active_wb else None + +if FreeCAD.GuiUp: + for name in FreeCADGui.listWorkbenches(): + wb = FreeCADGui.getWorkbench(name) + workbenches.append({ + "name": name, + "label": wb.MenuText if hasattr(wb, "MenuText") else name, + "icon": wb.Icon if hasattr(wb, "Icon") else "", + "is_active": name == active_name, + }) +else: + # Return common workbenches for headless mode + common = ["StartWorkbench", "PartWorkbench", "PartDesignWorkbench", + "DraftWorkbench", "SketcherWorkbench", "MeshWorkbench"] + for name in common: + workbenches.append({ + "name": name, + "label": name.replace("Workbench", ""), + "icon": "", + "is_active": False, + }) + +_result_ = workbenches +""" + result = await self.execute_python(code) + + if result.success and result.result: + return [WorkbenchInfo(**wb) for wb in result.result] + return [] + + async def activate_workbench(self, workbench_name: str) -> None: + """Activate a workbench.""" + code = f""" +if FreeCAD.GuiUp: + try: + FreeCADGui.activateWorkbench({workbench_name!r}) + _result_ = True + except Exception as e: + raise ValueError(f"Failed to activate workbench: {{e}}") +else: + _result_ = True # Silently succeed in headless mode +""" + result = await self.execute_python(code) + + if not result.success: + error_msg = result.error_traceback or "Failed to activate workbench" + raise ValueError(error_msg) + + # ========================================================================= + # Version and Environment + # ========================================================================= + + async def get_freecad_version(self) -> dict[str, Any]: + """Get FreeCAD version information.""" + code = """ +import sys +_result_ = { + "version": ".".join(str(x) for x in FreeCAD.Version()[:3]), + "version_tuple": FreeCAD.Version()[:3], + "build_date": FreeCAD.Version()[3] if len(FreeCAD.Version()) > 3 else "unknown", + "python_version": sys.version, + "gui_available": FreeCAD.GuiUp, +} +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + + return { + "version": "unknown", + "version_tuple": [], + "build_date": "unknown", + "python_version": "", + "gui_available": False, + } + + async def is_gui_available(self) -> bool: + """Check if FreeCAD GUI is available.""" + result = await self.execute_python("_result_ = FreeCAD.GuiUp") + return bool(result.success and result.result) + + # ========================================================================= + # Console + # ========================================================================= + + async def get_console_output(self, lines: int = 100) -> list[str]: + """Get recent console output.""" + code = f""" +# Try to get console output from FreeCAD +output_lines = [] + +# Try FreeCAD.Console if available +if hasattr(FreeCAD, 'Console'): + console = FreeCAD.Console + if hasattr(console, 'GetLog'): + log = console.GetLog() + if log: + output_lines = log.split('\\n')[-{lines}:] + +_result_ = output_lines +""" + result = await self.execute_python(code) + + if result.success and result.result: + return result.result + return [] diff --git a/src/freecad_mcp/config.py b/src/freecad_mcp/config.py new file mode 100644 index 0000000..5a8c46c --- /dev/null +++ b/src/freecad_mcp/config.py @@ -0,0 +1,112 @@ +"""Configuration management for FreeCAD MCP Server. + +This module handles all configuration settings for the MCP server, +including FreeCAD connection settings, execution limits, and logging. +""" + +from enum import Enum +from pathlib import Path +from typing import Annotated + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class FreecadMode(str, Enum): + """FreeCAD connection mode.""" + + EMBEDDED = "embedded" + SOCKET = "socket" + XMLRPC = "xmlrpc" + + +class TransportType(str, Enum): + """MCP transport type.""" + + STDIO = "stdio" + HTTP = "http" + + +class ServerConfig(BaseSettings): + """Configuration for the FreeCAD MCP server. + + Settings are loaded from environment variables with the FREECAD_ prefix. + For example, FREECAD_MODE sets the mode field. + + Attributes: + mode: Connection mode - 'embedded', 'socket', or 'xmlrpc'. + freecad_path: Path to FreeCAD's lib directory (for embedded mode). + socket_host: Hostname for socket/xmlrpc connection. + socket_port: Port for JSON-RPC socket connection (default 9876). + xmlrpc_port: Port for XML-RPC connection (default 9875, neka-nat compatible). + timeout_ms: Default execution timeout in milliseconds. + max_output_size: Maximum output size in bytes. + transport: MCP transport type. + http_port: Port for HTTP transport. + log_level: Logging level. + """ + + model_config = SettingsConfigDict( + env_prefix="FREECAD_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + # FreeCAD connection settings + mode: FreecadMode = FreecadMode.EMBEDDED + freecad_path: Annotated[ + Path | None, + Field( + description="Path to FreeCAD's lib directory", + alias="FREECAD_PATH", + ), + ] = None + + # Socket settings (for socket mode) + socket_host: Annotated[ + str, + Field(description="Socket/XML-RPC server hostname"), + ] = "localhost" + socket_port: Annotated[ + int, + Field(ge=1, le=65535, description="Socket server port (JSON-RPC)"), + ] = 9876 + xmlrpc_port: Annotated[ + int, + Field(ge=1, le=65535, description="XML-RPC server port (neka-nat compatible)"), + ] = 9875 + + # Execution limits + timeout_ms: Annotated[ + int, + Field(ge=1000, le=600000, description="Execution timeout in ms"), + ] = 30000 + max_output_size: Annotated[ + int, + Field(ge=1000, description="Maximum output size in bytes"), + ] = 1_000_000 + + # MCP transport settings + transport: TransportType = TransportType.STDIO + http_port: Annotated[ + int, + Field(ge=1, le=65535, description="HTTP server port"), + ] = 8000 + + # Logging + log_level: str = "INFO" + + # Security settings + enable_sandbox: bool = True + allow_file_access: bool = True + allow_network_access: bool = False + + +def get_config() -> ServerConfig: + """Get the server configuration. + + Returns: + ServerConfig instance populated from environment variables. + """ + return ServerConfig() diff --git a/src/freecad_mcp/freecad_plugin/__init__.py b/src/freecad_mcp/freecad_plugin/__init__.py new file mode 100644 index 0000000..6f00020 --- /dev/null +++ b/src/freecad_mcp/freecad_plugin/__init__.py @@ -0,0 +1,58 @@ +"""FreeCAD MCP Bridge Plugin. + +This package is installed into FreeCAD's Mod directory to provide +socket and XML-RPC servers for MCP communication. + +Based on learnings from competitive analysis: +- Queue-based GUI communication for thread safety (from neka-nat) +- JSON-RPC 2.0 protocol for modern integration (port 9876) +- XML-RPC compatibility mode for neka-nat addons (port 9875) + +To install, copy this directory to: +- macOS: ~/Library/Application Support/FreeCAD/Mod/MCPBridge/ +- Linux: ~/.local/share/FreeCAD/Mod/MCPBridge/ +- Windows: %APPDATA%/FreeCAD/Mod/MCPBridge/ + +Or use the justfile command: + just install-freecad-plugin +""" + +from freecad_mcp.freecad_plugin.server import ( + DEFAULT_SOCKET_PORT, + DEFAULT_XMLRPC_PORT, + FreecadMCPPlugin, +) + +__all__ = [ + "DEFAULT_SOCKET_PORT", + "DEFAULT_XMLRPC_PORT", + "FreecadMCPPlugin", + "start", +] + + +def start( + host: str = "localhost", + port: int = DEFAULT_SOCKET_PORT, + xmlrpc_port: int = DEFAULT_XMLRPC_PORT, + enable_xmlrpc: bool = True, +) -> FreecadMCPPlugin: + """Start the MCP bridge servers. + + Args: + host: Hostname to bind to. + port: Port for JSON-RPC socket server. + xmlrpc_port: Port for XML-RPC server. + enable_xmlrpc: Whether to enable XML-RPC server. + + Returns: + The running plugin instance. + """ + plugin = FreecadMCPPlugin( + host=host, + port=port, + xmlrpc_port=xmlrpc_port, + enable_xmlrpc=enable_xmlrpc, + ) + plugin.start() + return plugin diff --git a/src/freecad_mcp/freecad_plugin/gui_startup.py b/src/freecad_mcp/freecad_plugin/gui_startup.py new file mode 100755 index 0000000..7d73502 --- /dev/null +++ b/src/freecad_mcp/freecad_plugin/gui_startup.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""FreeCAD MCP Bridge Auto-Start Script for GUI mode. + +This script is run automatically when FreeCAD GUI starts via `just run-gui`. +It starts the MCP bridge servers to allow AI assistants to communicate with FreeCAD. + +Usage: + This script is passed to FreeCAD as a startup script: + /Applications/FreeCAD.app/Contents/Resources/bin/freecad gui_startup.py + +Note: This script imports FreecadMCPPlugin directly from server.py to avoid +triggering the MCP SDK import in freecad_mcp/__init__.py (which isn't available +in FreeCAD's embedded Python environment). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Check if we're running inside FreeCAD +try: + import FreeCAD +except ImportError: + print("ERROR: This script must be run inside FreeCAD.") + sys.exit(1) + +# Import the plugin server directly from the module file +# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server) +# because freecad_mcp/__init__.py imports the MCP SDK which isn't available +# in FreeCAD's embedded Python environment +script_dir = str(Path(__file__).resolve().parent) +sys.path.insert(0, script_dir) + +# Import and start the plugin +try: + from server import FreecadMCPPlugin # Direct import from same directory + + # Create and start the plugin + plugin = FreecadMCPPlugin( + host="localhost", + port=9876, # JSON-RPC socket port + xmlrpc_port=9875, # XML-RPC port (neka-nat compatible) + enable_xmlrpc=True, + ) + plugin.start() + + FreeCAD.Console.PrintMessage("\n") + FreeCAD.Console.PrintMessage("=" * 60 + "\n") + FreeCAD.Console.PrintMessage("MCP Bridge started!\n") + FreeCAD.Console.PrintMessage(" - XML-RPC: localhost:9875\n") + FreeCAD.Console.PrintMessage(" - Socket: localhost:9876\n") + FreeCAD.Console.PrintMessage("\n") + FreeCAD.Console.PrintMessage( + "You can now connect your MCP client (Claude Code, etc.) to FreeCAD.\n" + ) + FreeCAD.Console.PrintMessage("=" * 60 + "\n") +except Exception as e: + FreeCAD.Console.PrintError(f"Failed to start MCP Bridge: {e}\n") + import traceback + + traceback.print_exc() diff --git a/src/freecad_mcp/freecad_plugin/headless_server.py b/src/freecad_mcp/freecad_plugin/headless_server.py new file mode 100755 index 0000000..d52d2bc --- /dev/null +++ b/src/freecad_mcp/freecad_plugin/headless_server.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Headless FreeCAD MCP Bridge Server. + +This script starts the MCP bridge server in FreeCAD's headless mode. +It should be run with FreeCADCmd (the headless FreeCAD executable). + +Usage: + FreeCADCmd headless_server.py + # or + freecadcmd headless_server.py + +Note: In headless mode, GUI features like screenshots are not available. +For full functionality, use the StartMCPBridge macro in FreeCAD's GUI. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# Check if we're running inside FreeCAD +try: + import FreeCAD + + print(f"FreeCAD version: {FreeCAD.Version()[0]}.{FreeCAD.Version()[1]}") +except ImportError: + print("ERROR: This script must be run with FreeCADCmd or inside FreeCAD.") + print("") + print("Usage:") + print(" just run-headless") + print(" # or") + print(" FreeCADCmd headless_server.py") + print(" freecadcmd headless_server.py") + print("") + print("On macOS:") + print( + " /Applications/FreeCAD.app/Contents/Resources/bin/freecadcmd headless_server.py" + ) + print("") + print("On Linux:") + print(" freecadcmd headless_server.py") + sys.exit(1) + +# Import the plugin server directly from the module file +# We avoid importing through the package hierarchy (freecad_mcp.freecad_plugin.server) +# because freecad_mcp/__init__.py imports the MCP SDK which isn't available +# in FreeCAD's embedded Python environment +script_dir = str(Path(__file__).resolve().parent) +# Import directly from server.py in the same directory +sys.path.insert(0, script_dir) +from server import FreecadMCPPlugin # noqa: E402 + +# Create and run the plugin +plugin = FreecadMCPPlugin( + host="localhost", + port=9876, # JSON-RPC socket port + xmlrpc_port=9875, # XML-RPC port (neka-nat compatible) + enable_xmlrpc=True, +) + +# Start the plugin +plugin.start() + +# Print status messages with flush to ensure they appear immediately +# (FreeCAD's Python may have buffered stdout) +print("", flush=True) +print("=" * 60, flush=True) +print("MCP Bridge started in headless mode!", flush=True) +print(" - XML-RPC: localhost:9875", flush=True) +print(" - Socket: localhost:9876", flush=True) +print("", flush=True) +print( + "Note: Screenshot and view features are not available in headless mode.", flush=True +) +print("Press Ctrl+C to stop.", flush=True) +print("=" * 60, flush=True) +print("", flush=True) + +# Run forever (blocks until Ctrl+C) +plugin.run_forever() diff --git a/src/freecad_mcp/freecad_plugin/server.py b/src/freecad_mcp/freecad_plugin/server.py new file mode 100644 index 0000000..33d28b1 --- /dev/null +++ b/src/freecad_mcp/freecad_plugin/server.py @@ -0,0 +1,708 @@ +"""FreeCAD MCP Bridge Plugin - Socket Server with Queue-based Thread Safety. + +This module provides a socket server that runs inside FreeCAD to handle +MCP bridge requests. It must be executed within FreeCAD's Python environment. + +Design inspired by neka-nat/freecad-mcp (MIT License): +- Queue-based GUI communication for thread safety +- XML-RPC compatibility mode (port 9875) +- Screenshot capture with view type detection + +Attribution: + The queue-based thread safety pattern and XML-RPC protocol design were + inspired by neka-nat/freecad-mcp (https://github.com/neka-nat/freecad-mcp), + which is licensed under the MIT License. This implementation is a complete + rewrite with additional features (JSON-RPC 2.0, async socket server). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import json +import queue +import threading +import time +import traceback +import xmlrpc.server +from contextlib import redirect_stderr, redirect_stdout +from typing import Any + +# These imports only work inside FreeCAD +try: + import FreeCAD + import FreeCADGui + + FREECAD_AVAILABLE = True +except ImportError: + FREECAD_AVAILABLE = False + +# Default configuration +DEFAULT_SOCKET_PORT = 9876 +DEFAULT_XMLRPC_PORT = 9875 +QUEUE_POLL_INTERVAL_MS = 50 +STATUS_UPDATE_INTERVAL_MS = 5000 # Update status bar every 5 seconds + + +class ExecutionRequest: + """Represents a code execution request.""" + + def __init__( + self, + code: str, + timeout_ms: int = 30000, + request_id: str | None = None, + ) -> None: + """Initialize execution request. + + Args: + code: Python code to execute. + timeout_ms: Execution timeout in milliseconds. + request_id: Optional request ID for tracking. + """ + self.code = code + self.timeout_ms = timeout_ms + self.request_id = request_id + self.result: dict[str, Any] | None = None + self.completed = threading.Event() + + +class FreecadMCPPlugin: + """Plugin that runs inside FreeCAD to handle MCP bridge requests. + + This class creates servers that accept connections from the MCP server + and executes commands in FreeCAD's context using a thread-safe queue + system for GUI operations. + + Attributes: + socket_host: Hostname for socket server. + socket_port: Port for JSON-RPC socket server. + xmlrpc_port: Port for XML-RPC server. + """ + + def __init__( + self, + host: str = "localhost", + port: int = DEFAULT_SOCKET_PORT, + xmlrpc_port: int = DEFAULT_XMLRPC_PORT, + enable_xmlrpc: bool = True, + ) -> None: + """Initialize the plugin. + + Args: + host: Hostname to bind to. + port: Port for JSON-RPC socket server. + xmlrpc_port: Port for XML-RPC server. + enable_xmlrpc: Whether to enable XML-RPC server. + """ + self._host = host + self._port = port + self._xmlrpc_port = xmlrpc_port + self._enable_xmlrpc = enable_xmlrpc + + # Server instances + self._socket_server: asyncio.Server | None = None + self._xmlrpc_server: xmlrpc.server.SimpleXMLRPCServer | None = None + self._socket_loop: asyncio.AbstractEventLoop | None = None + + # Threading + self._socket_thread: threading.Thread | None = None + self._xmlrpc_thread: threading.Thread | None = None + self._running = False + + # Queue-based execution for thread safety (learned from neka-nat) + self._request_queue: queue.Queue[ExecutionRequest] = queue.Queue() + self._timer = None + self._queue_thread: threading.Thread | None = None + self._headless = False + + # Status bar tracking + self._status_timer = None + self._request_count = 0 + self._last_request_time: float | None = None + + def start(self) -> None: + """Start all servers.""" + if self._running: + return + + self._running = True + + # Start the queue processing timer on the main thread + self._start_queue_processor() + + # Start socket server + self._socket_thread = threading.Thread( + target=self._run_socket_server, + daemon=True, + name="MCP-Socket", + ) + self._socket_thread.start() + + # Start XML-RPC server if enabled + if self._enable_xmlrpc: + self._xmlrpc_thread = threading.Thread( + target=self._run_xmlrpc_server, + daemon=True, + name="MCP-XMLRPC", + ) + self._xmlrpc_thread.start() + + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage( + f"MCP Bridge started:\n - JSON-RPC: {self._host}:{self._port}\n" + ) + if self._enable_xmlrpc: + FreeCAD.Console.PrintMessage( + f" - XML-RPC: {self._host}:{self._xmlrpc_port}\n" + ) + + # Start status bar updates in GUI mode + self._start_status_updates() + + def stop(self) -> None: + """Stop all servers.""" + self._running = False + + # Stop status bar updates + self._stop_status_updates() + + # Stop queue processor timer (GUI mode) + if self._timer: + with contextlib.suppress(Exception): + self._timer.stop() + self._timer = None + + # Stop queue processor thread (headless mode) + if self._queue_thread: + self._queue_thread.join(timeout=2.0) + self._queue_thread = None + + # Stop socket server + if self._socket_loop and self._socket_server: + self._socket_loop.call_soon_threadsafe(self._socket_server.close) + + # Stop XML-RPC server + if self._xmlrpc_server: + self._xmlrpc_server.shutdown() + + # Wait for threads + if self._socket_thread: + self._socket_thread.join(timeout=5.0) + self._socket_thread = None + + if self._xmlrpc_thread: + self._xmlrpc_thread.join(timeout=5.0) + self._xmlrpc_thread = None + + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage("MCP Bridge stopped\n") + + def run_forever(self) -> None: + """Run the server indefinitely (for headless mode). + + This method blocks until interrupted (Ctrl+C) or stop() is called. + Use this when running FreeCAD in headless/console mode. + """ + self.start() + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage("Server running. Press Ctrl+C to stop.\n") + try: + while self._running: + time.sleep(0.5) + except KeyboardInterrupt: + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage("\nShutting down...\n") + finally: + self.stop() + + # ========================================================================= + # Status Bar Updates (GUI mode only) + # ========================================================================= + + def _start_status_updates(self) -> None: + """Start periodic status bar updates in GUI mode.""" + if not (FREECAD_AVAILABLE and FreeCAD.GuiUp): + return + + # Try to import Qt - need to check both PySide2 and PySide6 + QtCore = None + with contextlib.suppress(ImportError): + from PySide2 import QtCore # type: ignore[no-redef] + if QtCore is None: + with contextlib.suppress(ImportError): + from PySide6 import QtCore # type: ignore[no-redef] + + if QtCore is None: + return + + # Create timer for status updates + timer = QtCore.QTimer() + timer.timeout.connect(self._update_status_bar) + timer.start(STATUS_UPDATE_INTERVAL_MS) + self._status_timer = timer + + # Show initial status + self._update_status_bar() + + def _stop_status_updates(self) -> None: + """Stop status bar updates and clear the status.""" + if self._status_timer: + with contextlib.suppress(Exception): + self._status_timer.stop() + self._status_timer = None + + # Clear status bar message + if FREECAD_AVAILABLE and FreeCAD.GuiUp: + self._set_status_bar("") + + def _update_status_bar(self) -> None: + """Update the FreeCAD status bar with MCP bridge status.""" + if not (FREECAD_AVAILABLE and FreeCAD.GuiUp): + return + + # Build status message + ports = f"XML-RPC:{self._xmlrpc_port}" if self._enable_xmlrpc else "" + if ports: + ports = f" ({ports})" + + if self._request_count > 0: + # Show activity info + if self._last_request_time: + elapsed = time.time() - self._last_request_time + if elapsed < 60: + time_ago = f"{int(elapsed)}s ago" + else: + time_ago = f"{int(elapsed / 60)}m ago" + status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests | last: {time_ago}" + else: + status = f"🔌 MCP Bridge active{ports} | {self._request_count} requests" + else: + status = f"🔌 MCP Bridge running{ports} | waiting for connections..." + + self._set_status_bar(status) + + def _set_status_bar(self, message: str) -> None: + """Set the FreeCAD main window status bar message. + + Args: + message: Message to display in status bar. + """ + if not (FREECAD_AVAILABLE and FreeCAD.GuiUp): + return + + try: + main_window = FreeCADGui.getMainWindow() + if main_window: + status_bar = main_window.statusBar() + if status_bar: + if message: + # Show message persistently (0 = no timeout) + status_bar.showMessage(message, 0) + else: + status_bar.clearMessage() + except Exception: + # Silently ignore status bar errors + pass + + def _record_request(self) -> None: + """Record that a request was processed (for status tracking).""" + self._request_count += 1 + self._last_request_time = time.time() + + # ========================================================================= + # Queue-based Thread Safety (from neka-nat) + # ========================================================================= + + def _start_queue_processor(self) -> None: + """Start the queue processor on the main GUI thread or as background thread.""" + # Check if we're in GUI mode using FreeCAD.GuiUp + # Note: Qt (PySide) may be available even in headless mode, but without + # a running event loop, Qt timers won't fire. Use GuiUp to detect this. + gui_available = FREECAD_AVAILABLE and FreeCAD.GuiUp + + if gui_available: + # GUI mode: use Qt timer for thread-safe GUI operations + try: + from PySide2 import QtCore + except ImportError: + try: + from PySide6 import QtCore + except ImportError: + QtCore = None # type: ignore[assignment] + + if QtCore is not None: + timer = QtCore.QTimer() + timer.timeout.connect(self._process_queue) + timer.start(QUEUE_POLL_INTERVAL_MS) + self._timer = timer + return + + # Headless mode: use a background thread for queue processing + # In headless mode, there's no GUI thread concern, so direct + # processing in a background thread is safe + self._headless = True + self._queue_thread = threading.Thread( + target=self._run_queue_processor_loop, + daemon=True, + name="MCP-QueueProcessor", + ) + self._queue_thread.start() + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage( + "Running in headless mode (queue processor thread started)\n" + ) + + def _run_queue_processor_loop(self) -> None: + """Run queue processor in a loop for headless mode.""" + while self._running: + self._process_queue() + time.sleep(QUEUE_POLL_INTERVAL_MS / 1000.0) + + def _process_queue(self) -> None: + """Process pending execution requests on the main thread. + + This method is called periodically by a Qt timer to ensure + GUI operations happen on the main thread. + """ + while not self._request_queue.empty(): + try: + request = self._request_queue.get_nowait() + result = self._execute_code_sync(request.code) + request.result = result + request.completed.set() + # Track request for status bar + self._record_request() + except queue.Empty: + break + except Exception as e: + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintError(f"Queue processing error: {e}\n") + + def _execute_via_queue( + self, + code: str, + timeout_ms: int = 30000, + ) -> dict[str, Any]: + """Execute code via the queue system for thread safety. + + Args: + code: Python code to execute. + timeout_ms: Execution timeout in milliseconds. + + Returns: + Execution result dictionary. + """ + request = ExecutionRequest(code, timeout_ms) + self._request_queue.put(request) + + # Wait for completion + if request.completed.wait(timeout=timeout_ms / 1000): + return request.result or { + "success": False, + "error_type": "InternalError", + "error_message": "No result returned", + } + else: + return { + "success": False, + "error_type": "TimeoutError", + "error_message": f"Execution timed out after {timeout_ms}ms", + "execution_time_ms": timeout_ms, + } + + def _execute_code_sync(self, code: str) -> dict[str, Any]: + """Execute Python code synchronously (call on main thread only). + + Args: + code: Python code to execute. + + Returns: + Execution result dictionary. + """ + start = time.perf_counter() + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + exec_globals: dict[str, Any] = { + "__builtins__": __builtins__, + } + + if FREECAD_AVAILABLE: + exec_globals["FreeCAD"] = FreeCAD + exec_globals["App"] = FreeCAD + exec_globals["FreeCADGui"] = FreeCADGui + exec_globals["Gui"] = FreeCADGui + + try: + with redirect_stdout(stdout_capture), redirect_stderr(stderr_capture): + compiled = compile(code, "", "exec") + exec(compiled, exec_globals) # noqa: S102 + + elapsed = (time.perf_counter() - start) * 1000 + return { + "success": True, + "result": exec_globals.get("_result_"), + "stdout": stdout_capture.getvalue(), + "stderr": stderr_capture.getvalue(), + "execution_time_ms": elapsed, + } + + except Exception as e: + elapsed = (time.perf_counter() - start) * 1000 + return { + "success": False, + "result": None, + "stdout": stdout_capture.getvalue(), + "stderr": stderr_capture.getvalue(), + "execution_time_ms": elapsed, + "error_type": type(e).__name__, + "error_message": str(e), + "error_traceback": traceback.format_exc(), + } + + # ========================================================================= + # Socket Server (JSON-RPC 2.0) + # ========================================================================= + + def _run_socket_server(self) -> None: + """Run the asyncio event loop in background thread.""" + self._socket_loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._socket_loop) + + try: + self._socket_loop.run_until_complete(self._start_socket_server()) + self._socket_loop.run_forever() + finally: + self._socket_loop.close() + + async def _start_socket_server(self) -> None: + """Start the TCP server.""" + self._socket_server = await asyncio.start_server( + self._handle_socket_client, + self._host, + self._port, + ) + + async def _handle_socket_client( + self, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ) -> None: + """Handle a connected socket client. + + Args: + reader: Stream reader for incoming data. + writer: Stream writer for outgoing data. + """ + peer = writer.get_extra_info("peername") + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage(f"MCP client connected (socket): {peer}\n") + + try: + while self._running: + data = await reader.readline() + if not data: + break + + try: + request = json.loads(data.decode("utf-8")) + response = await self._process_jsonrpc_request(request) + except json.JSONDecodeError as e: + response = { + "jsonrpc": "2.0", + "id": None, + "error": { + "code": -32700, + "message": "Parse error", + "data": str(e), + }, + } + + response_data = json.dumps(response).encode("utf-8") + b"\n" + writer.write(response_data) + await writer.drain() + + except Exception as e: + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintError(f"MCP socket error: {e}\n") + finally: + if FREECAD_AVAILABLE: + FreeCAD.Console.PrintMessage(f"MCP client disconnected: {peer}\n") + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + + async def _process_jsonrpc_request( + self, + request: dict[str, Any], + ) -> dict[str, Any]: + """Process a JSON-RPC 2.0 request. + + Args: + request: JSON-RPC request dictionary. + + Returns: + JSON-RPC response dictionary. + """ + request_id = request.get("id") + method = request.get("method") + params = request.get("params", {}) + + # Handle ping specially (no queue needed) + if method == "ping": + return { + "jsonrpc": "2.0", + "id": request_id, + "result": {"pong": True, "timestamp": time.time()}, + } + + # Handle execute via queue + if method == "execute": + code = params.get("code", "") + timeout_ms = params.get("timeout_ms", 30000) + + # Execute via queue for thread safety + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, + lambda: self._execute_via_queue(code, timeout_ms), + ) + + return { + "jsonrpc": "2.0", + "id": request_id, + "result": result, + } + + # Unknown method + return { + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32601, + "message": "Method not found", + "data": f"Unknown method: {method}", + }, + } + + # ========================================================================= + # XML-RPC Server (neka-nat compatible) + # ========================================================================= + + def _run_xmlrpc_server(self) -> None: + """Run the XML-RPC server.""" + self._xmlrpc_server = xmlrpc.server.SimpleXMLRPCServer( + (self._host, self._xmlrpc_port), + allow_none=True, + logRequests=False, + ) + + # Register methods (type: ignore needed - xmlrpc types are overly restrictive) + self._xmlrpc_server.register_function(self._xmlrpc_execute, "execute") # type: ignore[arg-type] + self._xmlrpc_server.register_function(self._xmlrpc_ping, "ping") # type: ignore[arg-type] + self._xmlrpc_server.register_function(self._xmlrpc_get_view, "get_view") # type: ignore[arg-type] + self._xmlrpc_server.register_introspection_functions() + + while self._running: + self._xmlrpc_server.handle_request() + + def _xmlrpc_ping(self) -> dict[str, Any]: + """XML-RPC ping handler.""" + return {"pong": True, "timestamp": time.time()} + + def _xmlrpc_execute(self, code: str) -> dict[str, Any]: + """XML-RPC execute handler (neka-nat compatible). + + Args: + code: Python code to execute. + + Returns: + Execution result dictionary. + """ + return self._execute_via_queue(code, 30000) + + def _xmlrpc_get_view( + self, + width: int = 800, + height: int = 600, + view_type: str = "Isometric", + ) -> dict[str, Any]: + """XML-RPC get_view handler for screenshots (neka-nat compatible). + + Args: + width: Image width. + height: Image height. + view_type: View angle type. + + Returns: + Dictionary with base64 image data or error. + """ + code = f""" +import base64 +import tempfile +import os + +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available"}} +else: + doc = FreeCAD.ActiveDocument + if doc is None: + _result_ = {{"success": False, "error": "No active document"}} + else: + view = FreeCADGui.ActiveDocument.ActiveView + if view is None: + _result_ = {{"success": False, "error": "No active view"}} + else: + # Check view type + view_class = view.__class__.__name__ + if view_class not in ["View3DInventor", "View3DInventorPy"]: + _result_ = {{"success": False, "error": f"Cannot capture from {{view_class}}"}} + else: + # Set view angle + view_type = {view_type!r} + if view_type == "FitAll": + view.fitAll() + elif view_type == "Isometric": + view.viewIsometric() + elif view_type == "Front": + view.viewFront() + elif view_type == "Back": + view.viewRear() + elif view_type == "Top": + view.viewTop() + elif view_type == "Bottom": + view.viewBottom() + elif view_type == "Left": + view.viewLeft() + elif view_type == "Right": + view.viewRight() + + # Capture screenshot + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + temp_path = f.name + + view.saveImage(temp_path, {width}, {height}, "Current") + + with open(temp_path, "rb") as f: + image_data = base64.b64encode(f.read()).decode("utf-8") + + os.unlink(temp_path) + + _result_ = {{ + "success": True, + "data": image_data, + "format": "png", + "width": {width}, + "height": {height}, + }} +""" + result = self._execute_via_queue(code, 30000) + if result.get("success") and result.get("result"): + return result["result"] + return {"success": False, "error": result.get("error_message", "Unknown error")} + + +# Backwards compatibility +start = FreecadMCPPlugin diff --git a/src/freecad_mcp/prompts/__init__.py b/src/freecad_mcp/prompts/__init__.py new file mode 100644 index 0000000..6644d66 --- /dev/null +++ b/src/freecad_mcp/prompts/__init__.py @@ -0,0 +1,30 @@ +"""MCP prompt templates for FreeCAD. + +This package contains reusable prompt templates for common FreeCAD tasks. +Prompts guide users through complex workflows and provide best practices. + +Available prompts: + Design Workflows: + - design_part: Guided parametric part design + - create_sketch_guide: 2D sketch creation + - boolean_operations_guide: Shape combination + + Export/Import: + - export_guide: Export to various formats + - import_guide: Import from various formats + + Analysis: + - analyze_shape: Shape geometry analysis + - debug_model: Model troubleshooting + + Macro Development: + - macro_development: Macro creation guide + - python_api_reference: API quick reference + + Troubleshooting: + - troubleshooting: General issue resolution +""" + +from freecad_mcp.prompts.freecad import register_prompts + +__all__ = ["register_prompts"] diff --git a/src/freecad_mcp/prompts/freecad.py b/src/freecad_mcp/prompts/freecad.py new file mode 100644 index 0000000..1651241 --- /dev/null +++ b/src/freecad_mcp/prompts/freecad.py @@ -0,0 +1,739 @@ +"""FreeCAD MCP prompts for common CAD tasks. + +This module provides reusable prompt templates that help Claude +understand FreeCAD concepts and guide users through complex tasks. + +Prompt Categories: + - Design Workflows: Part design, sketching, modeling + - Export/Import: File format handling + - Analysis: Shape inspection, validation + - Macro Development: Scripting guidance + - Troubleshooting: Common issues and solutions +""" + +from collections.abc import Callable, Coroutine +from typing import Any + +from mcp.server.fastmcp import FastMCP + + +def register_prompts( + mcp: FastMCP, + get_bridge: Callable[[], Coroutine[Any, Any, Any]], # noqa: ARG001 +) -> None: + """Register FreeCAD prompts with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge (unused but kept + for interface consistency with other register functions). + """ + # ========================================================================= + # Design Workflow Prompts + # ========================================================================= + + @mcp.prompt() + async def design_part( + description: str, + units: str = "mm", + ) -> str: + """Generate a guided workflow for designing a parametric part. + + Use this prompt when a user wants to create a new part from scratch. + It provides step-by-step guidance for the PartDesign workflow. + + Args: + description: Natural language description of the desired part. + units: Unit system to use (mm, cm, m, in). + + Returns: + Structured prompt guiding through part design. + """ + return f"""# FreeCAD Part Design Workflow + +## Part Description +{description} + +## Recommended Approach + +### 1. Create a New Document +First, create a new document for this part: +- Use `create_document` with a descriptive name + +### 2. Set Up PartDesign Body +Create a PartDesign body to contain the parametric features: +- Use `create_partdesign_body` to create the body container +- This enables the parametric workflow with features + +### 3. Create Base Sketch +Design the base profile: +- Use `create_sketch` on the XY plane (or appropriate plane) +- Add geometry with `add_sketch_rectangle`, `add_sketch_circle`, etc. +- Close the sketch when complete + +### 4. Extrude the Base +Create the base 3D shape: +- Use `pad_sketch` to extrude the sketch +- Specify length in {units} + +### 5. Add Features +Add additional features as needed: +- `pocket_sketch` for cuts/holes +- `fillet_edges` for rounded edges +- `chamfer_edges` for beveled edges + +### 6. Verify and Export +When complete: +- Use `inspect_object` to verify dimensions +- Use `get_screenshot` to visualize the result +- Export with `export_step` or `export_stl` as needed + +## Units +All dimensions should be specified in **{units}**. +""" + + @mcp.prompt() + async def create_sketch_guide( + shape_type: str = "rectangle", + plane: str = "XY", + ) -> str: + """Guide for creating 2D sketches for part design. + + Args: + shape_type: Type of shape (rectangle, circle, polygon). + plane: Sketch plane (XY, XZ, YZ). + + Returns: + Sketch creation guidance. + """ + return f"""# FreeCAD Sketch Creation Guide + +## Target Shape: {shape_type} +## Sketch Plane: {plane} + +### Step 1: Create Sketch +Use `create_sketch` with plane="{plane}" to start a new sketch. + +### Step 2: Add Geometry + +{"#### Rectangle" if shape_type == "rectangle" else ""} +{"Use `add_sketch_rectangle` with:" if shape_type == "rectangle" else ""} +{"- x, y: Starting corner position" if shape_type == "rectangle" else ""} +{"- width, height: Rectangle dimensions" if shape_type == "rectangle" else ""} + +{"#### Circle" if shape_type == "circle" else ""} +{"Use `add_sketch_circle` with:" if shape_type == "circle" else ""} +{"- x, y: Center position" if shape_type == "circle" else ""} +{"- radius: Circle radius" if shape_type == "circle" else ""} + +{"#### Custom Polygon" if shape_type == "polygon" else ""} +{"Use `execute_python` with Part.makePolygon() for custom shapes." if shape_type == "polygon" else ""} + +### Step 3: Constrain the Sketch +For a fully constrained sketch: +- All geometry should have defined positions +- No free degrees of freedom + +### Step 4: Close and Use +The sketch can then be: +- Padded (extruded) with `pad_sketch` +- Pocketed (cut) with `pocket_sketch` +- Revolved with `execute_python` using PartDesign Revolution +""" + + @mcp.prompt() + async def boolean_operations_guide() -> str: + """Guide for performing boolean operations on shapes. + + Returns: + Boolean operations guidance. + """ + return """# FreeCAD Boolean Operations Guide + +Boolean operations combine two or more shapes into a new shape. + +## Available Operations + +### 1. Fuse (Union) +Combines two shapes into one: +``` +boolean_operation( + object1="Box", + object2="Cylinder", + operation="fuse", + result_name="FusedShape" +) +``` + +### 2. Cut (Difference) +Removes the second shape from the first: +``` +boolean_operation( + object1="Box", + object2="Cylinder", + operation="cut", + result_name="CutShape" +) +``` + +### 3. Common (Intersection) +Keeps only the overlapping region: +``` +boolean_operation( + object1="Box", + object2="Cylinder", + operation="common", + result_name="CommonShape" +) +``` + +## Tips +- Shapes must overlap for meaningful results +- The original objects remain in the document +- Use `set_object_visibility` to hide originals after operation +- Recompute the document after boolean operations +""" + + # ========================================================================= + # Export/Import Prompts + # ========================================================================= + + @mcp.prompt() + async def export_guide(target_format: str = "STEP") -> str: + """Guide for exporting FreeCAD models to various formats. + + Args: + target_format: Target export format (STEP, STL, OBJ, IGES). + + Returns: + Export guidance for the specified format. + """ + format_info = { + "STEP": { + "tool": "export_step", + "extension": ".step", + "description": "Standard for exchanging 3D CAD data between systems", + "best_for": "CAD interchange, preserves geometry precisely", + "params": "file_path, object_names (optional)", + }, + "STL": { + "tool": "export_stl", + "extension": ".stl", + "description": "Triangulated mesh format", + "best_for": "3D printing, mesh-based workflows", + "params": "file_path, object_names (optional), mesh_tolerance (default 0.1)", + }, + "OBJ": { + "tool": "export_obj", + "extension": ".obj", + "description": "Wavefront OBJ mesh format", + "best_for": "3D graphics, rendering, game engines", + "params": "file_path, object_names (optional)", + }, + "IGES": { + "tool": "export_iges", + "extension": ".iges", + "description": "Initial Graphics Exchange Specification", + "best_for": "Legacy CAD systems, surface data", + "params": "file_path, object_names (optional)", + }, + } + + info = format_info.get(target_format.upper(), format_info["STEP"]) + + return f"""# FreeCAD Export Guide: {target_format.upper()} + +## Format: {target_format.upper()} ({info["extension"]}) +{info["description"]} + +**Best for:** {info["best_for"]} + +## Export Command +Use the `{info["tool"]}` tool with parameters: +- {info["params"]} + +## Example +```python +{info["tool"]}( + file_path="/path/to/output{info["extension"]}", + object_names=["Part1", "Part2"] # Optional: exports all if not specified +) +``` + +## Pre-Export Checklist +1. Verify all objects are visible with `list_objects` +2. Check object validity with `inspect_object` +3. Recompute document if needed: `recompute_document` +4. Consider using `fit_all` and `get_screenshot` to verify visually + +## Post-Export +- Verify the exported file exists +- Check file size is reasonable +- Test import in target application if possible +""" + + @mcp.prompt() + async def import_guide(source_format: str = "STEP") -> str: + """Guide for importing models into FreeCAD. + + Args: + source_format: Source file format (STEP, STL). + + Returns: + Import guidance for the specified format. + """ + format_info = { + "STEP": { + "tool": "import_step", + "description": "Imports precise CAD geometry", + "notes": "Preserves feature boundaries, faces, and edges", + }, + "STL": { + "tool": "import_stl", + "description": "Imports triangulated mesh", + "notes": "Results in Mesh object, may need conversion for CAD operations", + }, + } + + info = format_info.get(source_format.upper(), format_info["STEP"]) + + return f"""# FreeCAD Import Guide: {source_format.upper()} + +## Format: {source_format.upper()} +{info["description"]} + +**Notes:** {info["notes"]} + +## Import Command +Use the `{info["tool"]}` tool: +```python +{info["tool"]}( + file_path="/path/to/file.{source_format.lower()}", + doc_name="TargetDocument" # Optional +) +``` + +## Post-Import Steps +1. List imported objects: `list_objects` +2. Inspect geometry: `inspect_object` on each object +3. Adjust view: `fit_all` to see all imported geometry +4. Take screenshot: `get_screenshot` to verify import + +## Common Issues +- Large files may take time to process +- Complex geometry may create many objects +- STL meshes need conversion for boolean operations +""" + + # ========================================================================= + # Analysis Prompts + # ========================================================================= + + @mcp.prompt() + async def analyze_shape() -> str: + """Guide for analyzing shape geometry and properties. + + Returns: + Shape analysis guidance. + """ + return """# FreeCAD Shape Analysis Guide + +## Quick Analysis +Use `inspect_object` with `include_shape=True` to get: +- Volume +- Surface area +- Bounding box +- Vertex/edge/face counts +- Validity status + +## Detailed Analysis with Python + +### Bounding Box +```python +execute_python(''' +obj = FreeCAD.ActiveDocument.getObject("ObjectName") +bb = obj.Shape.BoundBox +_result_ = { + "min": [bb.XMin, bb.YMin, bb.ZMin], + "max": [bb.XMax, bb.YMax, bb.ZMax], + "size": [bb.XLength, bb.YLength, bb.ZLength], + "center": [bb.Center.x, bb.Center.y, bb.Center.z] +} +''') +``` + +### Center of Mass +```python +execute_python(''' +obj = FreeCAD.ActiveDocument.getObject("ObjectName") +com = obj.Shape.CenterOfMass +_result_ = {"x": com.x, "y": com.y, "z": com.z} +''') +``` + +### Moments of Inertia +```python +execute_python(''' +obj = FreeCAD.ActiveDocument.getObject("ObjectName") +moi = obj.Shape.MatrixOfInertia +_result_ = { + "Ixx": moi.A11, "Iyy": moi.A22, "Izz": moi.A33, + "Ixy": moi.A12, "Ixz": moi.A13, "Iyz": moi.A23 +} +''') +``` + +## Validation +Check for geometry issues: +```python +execute_python(''' +obj = FreeCAD.ActiveDocument.getObject("ObjectName") +shape = obj.Shape +_result_ = { + "is_valid": shape.isValid(), + "is_closed": shape.isClosed() if hasattr(shape, 'isClosed') else None, + "has_shape": shape.ShapeType != "Compound" or len(shape.Solids) > 0 +} +''') +``` +""" + + @mcp.prompt() + async def debug_model() -> str: + """Guide for debugging FreeCAD model issues. + + Returns: + Model debugging guidance. + """ + return """# FreeCAD Model Debugging Guide + +## Common Issues and Solutions + +### 1. Recompute Errors +**Symptom:** Objects show error state, model doesn't update +**Solution:** +```python +recompute_document() # Force full recompute +``` + +### 2. Invalid Shape +**Symptom:** Boolean operations fail, export errors +**Diagnosis:** +```python +execute_python(''' +obj = FreeCAD.ActiveDocument.getObject("ObjectName") +_result_ = { + "valid": obj.Shape.isValid(), + "type": obj.Shape.ShapeType, + "check": obj.Shape.check() if hasattr(obj.Shape, 'check') else "N/A" +} +''') +``` + +### 3. Sketch Not Fully Constrained +**Symptom:** Sketch geometry moves unexpectedly +**Check constraints:** +```python +execute_python(''' +sketch = FreeCAD.ActiveDocument.getObject("SketchName") +_result_ = { + "dof": sketch.solve(), # Degrees of freedom + "constraint_count": sketch.ConstraintCount, + "geometry_count": sketch.GeometryCount +} +''') +``` + +### 4. Object Dependencies +**Symptom:** Can't delete object, unexpected behavior +**Check dependencies:** +```python +inspect_object("ObjectName") # Check children and parents +``` + +### 5. View Not Updating +**Symptom:** Display doesn't match model +**Solution:** +```python +fit_all() # Reset view +get_screenshot() # Force view update +``` + +## Diagnostic Workflow +1. `list_objects` - See all objects and their states +2. `inspect_object` on problematic objects +3. `get_console_output` - Check for error messages +4. `recompute_document` - Force update +5. `get_screenshot` - Visual verification +""" + + # ========================================================================= + # Macro Development Prompts + # ========================================================================= + + @mcp.prompt() + async def macro_development() -> str: + """Guide for developing FreeCAD macros. + + Returns: + Macro development guidance. + """ + return """# FreeCAD Macro Development Guide + +## Macro Structure +A FreeCAD macro is a Python script that automates tasks. + +### Basic Template +```python +# -*- coding: utf-8 -*- +# Macro: MacroName +# Description: What the macro does + +import FreeCAD +import FreeCADGui + +def main(): + # Get active document + doc = FreeCAD.ActiveDocument + if doc is None: + FreeCAD.Console.PrintError("No active document\\n") + return + + # Your code here + + doc.recompute() + FreeCAD.Console.PrintMessage("Macro completed\\n") + +if __name__ == "__main__": + main() +``` + +## Creating a Macro +Use `create_macro` to save a macro: +```python +create_macro( + name="MyMacro", + code="... macro code ...", + description="What it does" +) +``` + +Or use a template: +```python +create_macro_from_template( + template_name="part", # basic, part, sketch, gui, selection + macro_name="MyPartMacro" +) +``` + +## Available Templates +- **basic**: Minimal template +- **part**: Part creation with primitives +- **sketch**: 2D sketch operations +- **gui**: GUI interaction with message boxes +- **selection**: Working with selected objects + +## Running Macros +```python +run_macro("MacroName") +``` + +## Best Practices +1. Always check for active document +2. Use FreeCAD.Console for output +3. Call doc.recompute() after changes +4. Handle exceptions gracefully +5. Add descriptive comments +""" + + @mcp.prompt() + async def python_api_reference() -> str: + """Quick reference for common FreeCAD Python API operations. + + Returns: + Python API reference. + """ + return """# FreeCAD Python API Quick Reference + +## Document Operations +```python +# Create/get documents +doc = FreeCAD.newDocument("Name") +doc = FreeCAD.ActiveDocument +doc = FreeCAD.getDocument("Name") + +# Document methods +doc.recompute() +doc.save() +doc.saveAs("/path/to/file.FCStd") +``` + +## Object Operations +```python +# Create objects +box = doc.addObject("Part::Box", "MyBox") +cyl = doc.addObject("Part::Cylinder", "MyCyl") + +# Get objects +obj = doc.getObject("ObjectName") +all_objs = doc.Objects + +# Modify properties +obj.Length = 100 +obj.Placement = FreeCAD.Placement( + FreeCAD.Vector(x, y, z), + FreeCAD.Rotation(axis, angle) +) + +# Delete +doc.removeObject("ObjectName") +``` + +## Part Module +```python +import Part + +# Primitives +box = Part.makeBox(l, w, h) +cyl = Part.makeCylinder(r, h) +sphere = Part.makeSphere(r) + +# Boolean operations +fused = shape1.fuse(shape2) +cut = shape1.cut(shape2) +common = shape1.common(shape2) + +# Create from shape +Part.show(shape, "Name") +``` + +## Sketcher Module +```python +import Sketcher + +# Create sketch +sketch = doc.addObject("Sketcher::SketchObject", "Sketch") +sketch.MapMode = "FlatFace" + +# Add geometry +sketch.addGeometry(Part.LineSegment(p1, p2)) +sketch.addGeometry(Part.Circle(center, normal, radius)) + +# Add constraints +sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, 1, 2)) +sketch.addConstraint(Sketcher.Constraint("Horizontal", 0)) +``` + +## GUI Operations +```python +import FreeCADGui as Gui + +# View control +view = Gui.ActiveDocument.ActiveView +view.viewIsometric() +view.fitAll() +view.saveImage("/path/to/image.png", 800, 600) + +# Object visibility +obj.ViewObject.Visibility = True/False +obj.ViewObject.ShapeColor = (r, g, b) # 0.0-1.0 +``` + +## Vectors and Placement +```python +# Vector operations +v = FreeCAD.Vector(x, y, z) +v.Length +v.normalize() +v1.cross(v2) +v1.dot(v2) + +# Placement +p = FreeCAD.Placement() +p.Base = FreeCAD.Vector(x, y, z) +p.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45) +``` +""" + + # ========================================================================= + # Troubleshooting Prompts + # ========================================================================= + + @mcp.prompt() + async def troubleshooting() -> str: + """General troubleshooting guide for FreeCAD MCP. + + Returns: + Troubleshooting guidance. + """ + return """# FreeCAD MCP Troubleshooting Guide + +## Connection Issues + +### Cannot Connect to FreeCAD +1. Verify FreeCAD is running (for socket/xmlrpc modes) +2. Check the MCP plugin is started in FreeCAD +3. Verify port numbers match (default: 9876 socket, 9875 xmlrpc) + +**Check status:** +```python +get_connection_status() +``` + +### Connection Drops +- FreeCAD may be busy with long operations +- Try increasing timeout values +- Check FreeCAD console for errors + +## Execution Issues + +### Code Execution Timeout +- Increase timeout_ms parameter +- Break complex operations into smaller steps +- Check for infinite loops in code + +### No Result Returned +- Ensure you set `_result_ = value` in your code +- Check for exceptions in stderr + +**Debug execution:** +```python +execute_python(''' +try: + # Your code + _result_ = {"success": True, "data": result} +except Exception as e: + _result_ = {"success": False, "error": str(e)} +''') +``` + +## GUI Issues + +### Screenshots Fail +- Ensure GUI mode is available: `get_freecad_version()` +- Check for active document and view +- Verify view type supports screenshots + +### View Not Updating +```python +recompute_document() +fit_all() +``` + +## Model Issues + +### Boolean Operation Fails +- Check shapes are valid +- Ensure shapes overlap +- Try with simpler geometry first + +### Export Fails +- Verify objects have valid shapes +- Check file path is writable +- Ensure correct format for geometry type + +## Getting Help +1. Check console output: `get_console_output()` +2. Inspect problematic objects: `inspect_object()` +3. Verify document state: `list_documents()`, `list_objects()` +""" diff --git a/src/freecad_mcp/py.typed b/src/freecad_mcp/py.typed new file mode 100644 index 0000000..49dbc1c --- /dev/null +++ b/src/freecad_mcp/py.typed @@ -0,0 +1,2 @@ +# PEP 561 marker file +# This file indicates that this package supports type checking diff --git a/src/freecad_mcp/resources/__init__.py b/src/freecad_mcp/resources/__init__.py new file mode 100644 index 0000000..96ea6f8 --- /dev/null +++ b/src/freecad_mcp/resources/__init__.py @@ -0,0 +1,23 @@ +"""MCP resource implementations for FreeCAD. + +This package contains all MCP resource definitions for querying FreeCAD state. +Resources provide read-only access to FreeCAD's current state via URI-addressable +endpoints. + +Available resources: + - freecad://version - FreeCAD version information + - freecad://status - Connection and runtime status + - freecad://documents - List of open documents + - freecad://documents/{name} - Single document details + - freecad://documents/{name}/objects - Objects in a document + - freecad://objects/{doc_name}/{obj_name} - Object details + - freecad://workbenches - Available workbenches + - freecad://workbenches/active - Currently active workbench + - freecad://macros - Available macros + - freecad://console - Recent console output + - freecad://active-document - Currently active document +""" + +from freecad_mcp.resources.freecad import register_resources + +__all__ = ["register_resources"] diff --git a/src/freecad_mcp/resources/freecad.py b/src/freecad_mcp/resources/freecad.py new file mode 100644 index 0000000..da400cf --- /dev/null +++ b/src/freecad_mcp/resources/freecad.py @@ -0,0 +1,898 @@ +"""FreeCAD MCP resources for exposing FreeCAD state. + +This module provides MCP resources that expose FreeCAD's current state +as read-only data. Resources are URI-addressable data that Claude can +access to understand the current FreeCAD environment. + +Resource URIs: + - freecad://capabilities - Complete list of all available tools/resources + - freecad://version - FreeCAD version information + - freecad://status - Connection and runtime status + - freecad://documents - List of open documents + - freecad://documents/{name} - Single document details + - freecad://documents/{name}/objects - Objects in a document + - freecad://objects/{doc_name}/{obj_name} - Object details + - freecad://active-document - Currently active document + - freecad://workbenches - Available workbenches + - freecad://workbenches/active - Currently active workbench + - freecad://macros - Available macros + - freecad://console - Recent console output +""" + +import json +from typing import Any + + +def register_resources(mcp, get_bridge) -> None: + """Register FreeCAD resources with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.resource("freecad://version") + async def resource_version() -> str: + """Get FreeCAD version and build information. + + Returns: + JSON string containing: + - version: FreeCAD version string + - build_date: Build timestamp + - python_version: Python interpreter version + - gui_available: Whether GUI mode is active + """ + bridge = await get_bridge() + version_info = await bridge.get_freecad_version() + return json.dumps(version_info, indent=2) + + @mcp.resource("freecad://status") + async def resource_status() -> str: + """Get current FreeCAD connection and runtime status. + + Returns: + JSON string containing: + - connected: Connection state + - mode: Bridge mode (embedded, xmlrpc, socket) + - freecad_version: Version string + - gui_available: GUI availability + - last_ping_ms: Connection latency + - error: Any error message + """ + bridge = await get_bridge() + status = await bridge.get_status() + return json.dumps( + { + "connected": status.connected, + "mode": status.mode, + "freecad_version": status.freecad_version, + "gui_available": status.gui_available, + "last_ping_ms": status.last_ping_ms, + "error": status.error, + }, + indent=2, + ) + + @mcp.resource("freecad://documents") + async def resource_documents() -> str: + """Get list of all open FreeCAD documents. + + Returns: + JSON string containing list of documents, each with: + - name: Internal document name + - label: Display label + - path: File path (null if unsaved) + - object_count: Number of objects + - is_modified: Has unsaved changes + - active_object: Currently selected object + """ + bridge = await get_bridge() + docs = await bridge.get_documents() + doc_list = [ + { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "object_count": len(doc.objects), + "is_modified": doc.is_modified, + "active_object": doc.active_object, + } + for doc in docs + ] + return json.dumps(doc_list, indent=2) + + @mcp.resource("freecad://documents/{name}") + async def resource_document(name: str) -> str: + """Get detailed information about a specific document. + + Args: + name: Document name to query. + + Returns: + JSON string containing: + - name: Internal document name + - label: Display label + - path: File path (null if unsaved) + - objects: List of object names + - is_modified: Has unsaved changes + - active_object: Currently selected object + """ + bridge = await get_bridge() + docs = await bridge.get_documents() + + for doc in docs: + if doc.name == name: + return json.dumps( + { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "objects": doc.objects, + "is_modified": doc.is_modified, + "active_object": doc.active_object, + }, + indent=2, + ) + + return json.dumps({"error": f"Document '{name}' not found"}, indent=2) + + @mcp.resource("freecad://documents/{name}/objects") + async def resource_document_objects(name: str) -> str: + """Get list of objects in a specific document. + + Args: + name: Document name to query. + + Returns: + JSON string containing list of objects, each with: + - name: Object name + - label: Display label + - type_id: FreeCAD type identifier + - visibility: Whether object is visible + """ + bridge = await get_bridge() + objects = await bridge.get_objects(doc_name=name) + obj_list = [ + { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + "visibility": obj.visibility, + } + for obj in objects + ] + return json.dumps(obj_list, indent=2) + + @mcp.resource("freecad://objects/{doc_name}/{obj_name}") + async def resource_object(doc_name: str, obj_name: str) -> str: + """Get detailed information about a specific object. + + Args: + doc_name: Document containing the object. + obj_name: Object name to query. + + Returns: + JSON string containing: + - name: Object name + - label: Display label + - type_id: FreeCAD type identifier + - properties: Dictionary of property values + - shape_info: Shape geometry (if applicable) + - children: Dependent object names + - parents: Parent object names + - visibility: Display visibility + """ + bridge = await get_bridge() + obj = await bridge.get_object(obj_name, doc_name=doc_name) + + # Filter properties to only include serializable values + safe_properties = _make_json_safe(obj.properties) + + return json.dumps( + { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + "properties": safe_properties, + "shape_info": obj.shape_info, + "children": obj.children, + "parents": obj.parents, + "visibility": obj.visibility, + }, + indent=2, + ) + + @mcp.resource("freecad://workbenches") + async def resource_workbenches() -> str: + """Get list of available FreeCAD workbenches. + + Returns: + JSON string containing list of workbenches, each with: + - name: Workbench internal name + - label: Display label + - is_active: Whether currently active + """ + bridge = await get_bridge() + workbenches = await bridge.get_workbenches() + wb_list = [ + { + "name": wb.name, + "label": wb.label, + "is_active": wb.is_active, + } + for wb in workbenches + ] + return json.dumps(wb_list, indent=2) + + @mcp.resource("freecad://workbenches/active") + async def resource_active_workbench() -> str: + """Get the currently active workbench. + + Returns: + JSON string containing active workbench info or null. + """ + bridge = await get_bridge() + workbenches = await bridge.get_workbenches() + for wb in workbenches: + if wb.is_active: + return json.dumps( + { + "name": wb.name, + "label": wb.label, + }, + indent=2, + ) + return json.dumps(None) + + @mcp.resource("freecad://macros") + async def resource_macros() -> str: + """Get list of available FreeCAD macros. + + Returns: + JSON string containing list of macros, each with: + - name: Macro name (without extension) + - path: Full file path + - description: Macro description + - is_system: Whether it's a system macro + """ + bridge = await get_bridge() + macros = await bridge.get_macros() + macro_list = [ + { + "name": macro.name, + "path": macro.path, + "description": macro.description, + "is_system": macro.is_system, + } + for macro in macros + ] + return json.dumps(macro_list, indent=2) + + @mcp.resource("freecad://console") + async def resource_console() -> str: + """Get recent FreeCAD console output. + + Returns: + JSON string containing: + - lines: List of console output lines + - count: Number of lines + """ + bridge = await get_bridge() + lines = await bridge.get_console_output(lines=100) + return json.dumps( + { + "lines": lines, + "count": len(lines), + }, + indent=2, + ) + + @mcp.resource("freecad://active-document") + async def resource_active_document() -> str: + """Get the currently active document. + + Returns: + JSON string containing active document info or null. + """ + bridge = await get_bridge() + doc = await bridge.get_active_document() + if doc is None: + return json.dumps(None) + return json.dumps( + { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "objects": doc.objects, + "is_modified": doc.is_modified, + "active_object": doc.active_object, + }, + indent=2, + ) + + @mcp.resource("freecad://capabilities") + async def resource_capabilities() -> str: + """Get comprehensive list of all MCP capabilities. + + This resource provides a complete catalog of all available tools, + resources, and prompts. Use this to discover what functionality + is available when working with the FreeCAD MCP server. + + Returns: + JSON string containing: + - tools: Dict of tool categories with tool definitions + - resources: List of available resource URIs + - prompts: List of available prompt names + - examples: Common usage patterns + """ + capabilities = { + "description": "FreeCAD MCP Server - Control FreeCAD via Model Context Protocol", + "tools": { + "execution": { + "description": "Execute Python code and access console", + "tools": [ + { + "name": "execute_python", + "description": "Execute arbitrary Python code in FreeCAD's context. Use _result_ = value to return data.", + "key_params": ["code", "timeout_ms"], + }, + { + "name": "get_console_output", + "description": "Get recent FreeCAD console output for debugging", + "key_params": ["lines"], + }, + { + "name": "get_console_log", + "description": "Alternative console log access", + "key_params": ["lines"], + }, + { + "name": "get_freecad_version", + "description": "Get FreeCAD version, build date, Python version", + "key_params": [], + }, + { + "name": "get_connection_status", + "description": "Check MCP bridge connection status and latency", + "key_params": [], + }, + { + "name": "get_mcp_server_environment", + "description": "Get MCP server environment info (OS, hostname, Docker detection)", + "key_params": [], + }, + ], + }, + "documents": { + "description": "Document management", + "tools": [ + { + "name": "list_documents", + "description": "List all open FreeCAD documents", + "key_params": [], + }, + { + "name": "get_active_document", + "description": "Get info about currently active document", + "key_params": [], + }, + { + "name": "create_document", + "description": "Create a new FreeCAD document", + "key_params": ["name"], + }, + { + "name": "open_document", + "description": "Open an existing .FCStd file", + "key_params": ["path"], + }, + { + "name": "save_document", + "description": "Save document to disk", + "key_params": ["doc_name", "path"], + }, + { + "name": "close_document", + "description": "Close a document", + "key_params": ["doc_name"], + }, + { + "name": "recompute_document", + "description": "Force recomputation of document features", + "key_params": ["doc_name"], + }, + ], + }, + "objects": { + "description": "Object creation and manipulation", + "tools": [ + { + "name": "list_objects", + "description": "List all objects in a document", + "key_params": ["doc_name"], + }, + { + "name": "inspect_object", + "description": "Get detailed info about an object", + "key_params": ["object_name", "doc_name"], + }, + { + "name": "create_box", + "description": "Create Part::Box primitive", + "key_params": ["length", "width", "height"], + }, + { + "name": "create_cylinder", + "description": "Create Part::Cylinder primitive", + "key_params": ["radius", "height"], + }, + { + "name": "create_sphere", + "description": "Create Part::Sphere primitive", + "key_params": ["radius"], + }, + { + "name": "create_cone", + "description": "Create Part::Cone primitive", + "key_params": ["radius1", "radius2", "height"], + }, + { + "name": "create_torus", + "description": "Create Part::Torus primitive", + "key_params": ["radius1", "radius2"], + }, + { + "name": "boolean_operation", + "description": "Union, cut, or intersection operations", + "key_params": ["operation", "object1", "object2"], + }, + { + "name": "edit_object", + "description": "Modify object properties", + "key_params": ["object_name", "properties"], + }, + { + "name": "delete_object", + "description": "Delete an object", + "key_params": ["object_name"], + }, + { + "name": "set_placement", + "description": "Set object position and rotation", + "key_params": ["object_name", "x", "y", "z"], + }, + { + "name": "copy_object", + "description": "Create a copy of an object", + "key_params": ["object_name"], + }, + { + "name": "mirror_object", + "description": "Mirror object across a plane", + "key_params": ["object_name", "plane"], + }, + ], + }, + "selection": { + "description": "Selection management", + "tools": [ + { + "name": "get_selection", + "description": "Get currently selected objects", + "key_params": ["doc_name"], + }, + { + "name": "set_selection", + "description": "Select specific objects", + "key_params": ["object_names"], + }, + { + "name": "clear_selection", + "description": "Clear current selection", + "key_params": [], + }, + ], + }, + "partdesign": { + "description": "Parametric modeling with PartDesign workbench", + "tools": [ + { + "name": "create_partdesign_body", + "description": "Create a PartDesign::Body container", + "key_params": ["name"], + }, + { + "name": "create_sketch", + "description": "Create sketch on plane or face", + "key_params": ["body_name", "plane"], + }, + { + "name": "add_sketch_rectangle", + "description": "Add rectangle to sketch", + "key_params": ["sketch_name", "x", "y", "width", "height"], + }, + { + "name": "add_sketch_circle", + "description": "Add circle to sketch", + "key_params": ["sketch_name", "x", "y", "radius"], + }, + { + "name": "add_sketch_line", + "description": "Add line to sketch", + "key_params": ["sketch_name", "x1", "y1", "x2", "y2"], + }, + { + "name": "add_sketch_arc", + "description": "Add arc to sketch", + "key_params": [ + "sketch_name", + "center_x", + "center_y", + "radius", + ], + }, + { + "name": "add_sketch_point", + "description": "Add point to sketch (for holes)", + "key_params": ["sketch_name", "x", "y"], + }, + { + "name": "pad_sketch", + "description": "Extrude sketch (additive)", + "key_params": ["body_name", "sketch_name", "length"], + }, + { + "name": "pocket_sketch", + "description": "Cut using sketch (subtractive)", + "key_params": ["body_name", "sketch_name", "length"], + }, + { + "name": "revolution_sketch", + "description": "Revolve sketch around axis", + "key_params": ["body_name", "sketch_name", "axis", "angle"], + }, + { + "name": "create_hole", + "description": "Create parametric hole feature", + "key_params": [ + "body_name", + "sketch_name", + "diameter", + "depth", + ], + }, + { + "name": "fillet_edges", + "description": "Add fillets to edges", + "key_params": ["body_name", "edges", "radius"], + }, + { + "name": "chamfer_edges", + "description": "Add chamfers to edges", + "key_params": ["body_name", "edges", "size"], + }, + { + "name": "loft_sketches", + "description": "Create loft between sketches", + "key_params": ["body_name", "sketch_names"], + }, + { + "name": "sweep_sketch", + "description": "Sweep sketch along path", + "key_params": [ + "body_name", + "profile_sketch", + "path_sketch", + ], + }, + ], + }, + "patterns": { + "description": "Pattern and transform features", + "tools": [ + { + "name": "linear_pattern", + "description": "Create linear pattern", + "key_params": [ + "body_name", + "feature_name", + "direction", + "count", + ], + }, + { + "name": "polar_pattern", + "description": "Create circular/polar pattern", + "key_params": [ + "body_name", + "feature_name", + "axis", + "count", + ], + }, + { + "name": "mirrored_feature", + "description": "Mirror feature across plane", + "key_params": ["body_name", "feature_name", "plane"], + }, + ], + }, + "view": { + "description": "View and GUI control (some require GUI mode)", + "tools": [ + { + "name": "get_screenshot", + "description": "Capture 3D view screenshot (GUI only)", + "key_params": ["file_path", "width", "height"], + }, + { + "name": "set_view_angle", + "description": "Set camera to standard views", + "key_params": ["angle"], + }, + { + "name": "fit_all", + "description": "Zoom to fit all objects", + "key_params": [], + }, + { + "name": "zoom_in", + "description": "Zoom in", + "key_params": ["factor"], + }, + { + "name": "zoom_out", + "description": "Zoom out", + "key_params": ["factor"], + }, + { + "name": "set_object_visibility", + "description": "Show/hide objects (GUI only)", + "key_params": ["object_name", "visible"], + }, + { + "name": "set_display_mode", + "description": "Set display mode (wireframe, shaded)", + "key_params": ["object_name", "mode"], + }, + { + "name": "set_object_color", + "description": "Change object color (GUI only)", + "key_params": ["object_name", "r", "g", "b"], + }, + { + "name": "list_workbenches", + "description": "List available workbenches", + "key_params": [], + }, + { + "name": "activate_workbench", + "description": "Switch workbench", + "key_params": ["workbench_name"], + }, + { + "name": "recompute", + "description": "Recompute document", + "key_params": ["doc_name"], + }, + ], + }, + "undo_redo": { + "description": "Undo/redo operations", + "tools": [ + { + "name": "undo", + "description": "Undo last operation", + "key_params": ["doc_name"], + }, + { + "name": "redo", + "description": "Redo undone operation", + "key_params": ["doc_name"], + }, + { + "name": "get_undo_redo_status", + "description": "Get available undo/redo operations", + "key_params": ["doc_name"], + }, + ], + }, + "export_import": { + "description": "File export and import", + "tools": [ + { + "name": "export_step", + "description": "Export to STEP format", + "key_params": ["object_names", "file_path"], + }, + { + "name": "export_stl", + "description": "Export to STL format (3D printing)", + "key_params": ["object_names", "file_path"], + }, + { + "name": "export_3mf", + "description": "Export to 3MF format", + "key_params": ["object_names", "file_path"], + }, + { + "name": "export_obj", + "description": "Export to OBJ format", + "key_params": ["object_names", "file_path"], + }, + { + "name": "export_iges", + "description": "Export to IGES format", + "key_params": ["object_names", "file_path"], + }, + { + "name": "import_step", + "description": "Import STEP file", + "key_params": ["file_path"], + }, + { + "name": "import_stl", + "description": "Import STL file", + "key_params": ["file_path"], + }, + ], + }, + "macros": { + "description": "Macro management", + "tools": [ + { + "name": "list_macros", + "description": "List available macros", + "key_params": [], + }, + { + "name": "run_macro", + "description": "Execute a macro", + "key_params": ["macro_name"], + }, + { + "name": "create_macro", + "description": "Create new macro", + "key_params": ["macro_name", "code"], + }, + { + "name": "read_macro", + "description": "Read macro source code", + "key_params": ["macro_name"], + }, + { + "name": "delete_macro", + "description": "Delete a macro", + "key_params": ["macro_name"], + }, + ], + }, + "parts_library": { + "description": "Parts library access", + "tools": [ + { + "name": "list_parts_library", + "description": "List parts in library", + "key_params": [], + }, + { + "name": "insert_part_from_library", + "description": "Insert part from library", + "key_params": ["part_path"], + }, + ], + }, + }, + "resources": [ + { + "uri": "freecad://capabilities", + "description": "This resource - lists all available capabilities", + }, + { + "uri": "freecad://version", + "description": "FreeCAD version and build information", + }, + { + "uri": "freecad://status", + "description": "Connection status, mode, GUI availability", + }, + { + "uri": "freecad://documents", + "description": "List of all open documents", + }, + { + "uri": "freecad://documents/{name}", + "description": "Details of a specific document", + }, + { + "uri": "freecad://documents/{name}/objects", + "description": "Objects in a specific document", + }, + { + "uri": "freecad://objects/{doc_name}/{obj_name}", + "description": "Detailed object info with properties", + }, + { + "uri": "freecad://active-document", + "description": "Currently active document", + }, + { + "uri": "freecad://workbenches", + "description": "Available FreeCAD workbenches", + }, + { + "uri": "freecad://workbenches/active", + "description": "Currently active workbench", + }, + { + "uri": "freecad://macros", + "description": "Available FreeCAD macros", + }, + { + "uri": "freecad://console", + "description": "Recent console output (debugging)", + }, + ], + "prompts": [ + { + "name": "freecad-help", + "description": "Get help on FreeCAD MCP capabilities", + }, + { + "name": "create-parametric-part", + "description": "Guide for creating parametric parts", + }, + { + "name": "debug-model", + "description": "Help debug model issues", + }, + ], + "examples": { + "debug_macro": { + "description": "Debug a macro by checking console output", + "steps": [ + "Use get_console_output(lines=50) to see recent errors", + "Use execute_python to inspect document state", + ], + }, + "create_simple_part": { + "description": "Create a basic parametric part", + "steps": [ + "create_document(name='MyPart')", + "create_partdesign_body(name='Body')", + "create_sketch(body_name='Body', plane='XY_Plane')", + "add_sketch_rectangle(...)", + "pad_sketch(...)", + ], + }, + "export_for_printing": { + "description": "Export model for 3D printing", + "steps": [ + "export_stl(object_names=['Body'], file_path='...')", + "Or export_3mf for color/material support", + ], + }, + }, + } + return json.dumps(capabilities, indent=2) + + +def _make_json_safe(obj: Any) -> Any: + """Convert an object to be JSON serializable. + + Args: + obj: Object to convert. + + Returns: + JSON-safe representation of the object. + """ + if obj is None: + return None + if isinstance(obj, str | int | float | bool): + return obj + if isinstance(obj, list | tuple): + return [_make_json_safe(item) for item in obj] + if isinstance(obj, dict): + return {str(k): _make_json_safe(v) for k, v in obj.items()} + # Convert other types to string representation + return str(obj) diff --git a/src/freecad_mcp/server.py b/src/freecad_mcp/server.py new file mode 100644 index 0000000..f7ba93d --- /dev/null +++ b/src/freecad_mcp/server.py @@ -0,0 +1,190 @@ +"""FreeCAD MCP Server - Main entry point. + +This module provides the main MCP server implementation for FreeCAD +integration with AI assistants (Claude, GPT, and other MCP-compatible tools). +It exposes tools, resources, and prompts for interacting with FreeCAD. + +Features: + - Full Python console access (GUI and headless modes) + - Document and object management + - PartDesign workflow (sketches, pads, pockets, fillets) + - Import/export (STEP, STL, OBJ, IGES) + - Macro management + - Screenshot capture + - Multiple connection modes (embedded, XML-RPC, socket) + +Example: + Run as a module:: + + $ python -m freecad_mcp.server + + Or use the installed command:: + + $ freecad-mcp + + With environment variables:: + + $ FREECAD_MODE=socket FREECAD_HOST=localhost freecad-mcp +""" + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +from mcp.server.fastmcp import FastMCP + +from freecad_mcp.config import FreecadMode, TransportType, get_config + +if TYPE_CHECKING: + from freecad_mcp.bridge.base import FreecadBridge + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Global bridge instance (initialized on startup via lifespan) +_bridge: Any = None + + +async def get_bridge() -> "FreecadBridge": + """Get the active FreeCAD bridge. + + Returns: + The active FreecadBridge instance. + + Raises: + RuntimeError: If bridge is not initialized. + """ + if _bridge is None: + msg = "FreeCAD bridge not initialized" + raise RuntimeError(msg) + return _bridge + + +@asynccontextmanager +async def lifespan(_server: FastMCP) -> AsyncIterator[None]: + """Manage FreeCAD bridge lifecycle. + + This async context manager initializes the FreeCAD bridge on startup + and disconnects it on shutdown. + + Args: + _server: The FastMCP server instance (unused). + + Yields: + None - the bridge is stored in the global _bridge variable. + """ + global _bridge + config = get_config() + + logger.info("Initializing FreeCAD bridge...") + + if config.mode == FreecadMode.EMBEDDED: + from freecad_mcp.bridge.embedded import EmbeddedBridge + + _bridge = EmbeddedBridge( + freecad_path=str(config.freecad_path) if config.freecad_path else None, + ) + logger.info("Using embedded bridge (headless mode)") + + elif config.mode == FreecadMode.XMLRPC: + from freecad_mcp.bridge.xmlrpc import XmlRpcBridge + + _bridge = XmlRpcBridge( + host=config.socket_host, + port=config.xmlrpc_port, + ) + logger.info( + "Using XML-RPC bridge: %s:%d", config.socket_host, config.xmlrpc_port + ) + + else: # SOCKET mode + from freecad_mcp.bridge.socket import SocketBridge + + _bridge = SocketBridge( + host=config.socket_host, + port=config.socket_port, + ) + logger.info( + "Using socket bridge: %s:%d", config.socket_host, config.socket_port + ) + + await _bridge.connect() + logger.info("FreeCAD bridge connected") + + # Log FreeCAD version + try: + version = await _bridge.get_freecad_version() + logger.info( + "FreeCAD %s (GUI: %s)", + version.get("version", "unknown"), + "available" if version.get("gui_available") else "headless", + ) + except Exception as e: + logger.warning("Could not get FreeCAD version: %s", e) + + try: + yield + finally: + # Shutdown + if _bridge: + logger.info("Disconnecting FreeCAD bridge...") + await _bridge.disconnect() + _bridge = None + + +# Create the MCP server instance with lifespan +mcp = FastMCP( + name="freecad-mcp", + lifespan=lifespan, +) + + +def register_all_components() -> None: + """Register all MCP components (tools, resources, prompts).""" + # Register tools + from freecad_mcp.tools import register_all_tools + + register_all_tools(mcp, get_bridge) + + # Register resources + from freecad_mcp.resources import register_resources + + register_resources(mcp, get_bridge) + + # Register prompts + from freecad_mcp.prompts import register_prompts + + register_prompts(mcp, get_bridge) + + +# Register all components +register_all_components() + + +def main() -> None: + """Run the FreeCAD MCP server.""" + config = get_config() + + # Set up logging + logging.getLogger().setLevel(config.log_level) + logger.info("Starting FreeCAD MCP server") + logger.info("Mode: %s", config.mode.value) + logger.info("Transport: %s", config.transport.value) + + # Run the server + if config.transport == TransportType.HTTP: + logger.info("Starting HTTP transport on port %d", config.http_port) + mcp.run( # type: ignore[call-arg] + transport="streamable-http", + host="0.0.0.0", # noqa: S104 + port=config.http_port, + ) + else: + logger.info("Starting stdio transport") + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/src/freecad_mcp/tools/__init__.py b/src/freecad_mcp/tools/__init__.py new file mode 100644 index 0000000..ac3a407 --- /dev/null +++ b/src/freecad_mcp/tools/__init__.py @@ -0,0 +1,48 @@ +"""MCP tool implementations for FreeCAD. + +This package contains all MCP tool definitions for interacting with FreeCAD. +Tools are organized by category: + +- execution: Python code execution tools +- documents: Document management tools +- objects: Object creation and manipulation tools +- partdesign: PartDesign workbench tools +- export: Export functionality tools +- macros: Macro management tools +- view: View and screenshot tools +""" + +from freecad_mcp.tools.documents import register_document_tools +from freecad_mcp.tools.execution import register_execution_tools +from freecad_mcp.tools.export import register_export_tools +from freecad_mcp.tools.macros import register_macro_tools +from freecad_mcp.tools.objects import register_object_tools +from freecad_mcp.tools.partdesign import register_partdesign_tools +from freecad_mcp.tools.view import register_view_tools + +__all__ = [ + "register_all_tools", + "register_document_tools", + "register_execution_tools", + "register_export_tools", + "register_macro_tools", + "register_object_tools", + "register_partdesign_tools", + "register_view_tools", +] + + +def register_all_tools(mcp, get_bridge_func) -> None: + """Register all FreeCAD tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge_func: Async function to get the active bridge. + """ + register_execution_tools(mcp, get_bridge_func) + register_document_tools(mcp, get_bridge_func) + register_object_tools(mcp, get_bridge_func) + register_partdesign_tools(mcp, get_bridge_func) + register_export_tools(mcp, get_bridge_func) + register_macro_tools(mcp, get_bridge_func) + register_view_tools(mcp, get_bridge_func) diff --git a/src/freecad_mcp/tools/documents.py b/src/freecad_mcp/tools/documents.py new file mode 100644 index 0000000..c2029f0 --- /dev/null +++ b/src/freecad_mcp/tools/documents.py @@ -0,0 +1,205 @@ +"""Document management tools for FreeCAD MCP server. + +This module provides tools for managing FreeCAD documents: +creating, opening, saving, closing, and listing documents. +""" + +from typing import Any + + +def register_document_tools(mcp, get_bridge) -> None: + """Register document-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def list_documents() -> list[dict[str, Any]]: + """List all open FreeCAD documents. + + Returns: + List of dictionaries, each containing: + - name: Internal document name + - label: Display label + - path: File path (None if not saved) + - is_modified: Whether document has unsaved changes + - object_count: Number of objects in document + - active_object: Name of currently active object (None if none) + """ + bridge = await get_bridge() + docs = await bridge.get_documents() + return [ + { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "is_modified": doc.is_modified, + "object_count": len(doc.objects), + "active_object": doc.active_object, + } + for doc in docs + ] + + @mcp.tool() + async def get_active_document() -> dict[str, Any] | None: + """Get the currently active FreeCAD document. + + Returns: + Dictionary with document information, or None if no active document: + - name: Internal document name + - label: Display label + - path: File path (None if not saved) + - is_modified: Whether document has unsaved changes + - objects: List of object names + - active_object: Name of currently active object (None if none) + """ + bridge = await get_bridge() + doc = await bridge.get_active_document() + if doc is None: + return None + return { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "is_modified": doc.is_modified, + "objects": doc.objects, + "active_object": doc.active_object, + } + + @mcp.tool() + async def create_document( + name: str = "Unnamed", + label: str | None = None, + ) -> dict[str, Any]: + """Create a new FreeCAD document. + + Args: + name: Internal document name (no spaces allowed, will be sanitized). + label: Display label (can contain spaces). Defaults to name. + + Returns: + Dictionary with created document information: + - name: Internal document name + - label: Display label + - path: File path (None for new document) + """ + bridge = await get_bridge() + doc = await bridge.create_document(name, label) + return { + "name": doc.name, + "label": doc.label, + "path": doc.path, + } + + @mcp.tool() + async def open_document(path: str) -> dict[str, Any]: + """Open an existing FreeCAD document from file. + + Args: + path: Full path to the .FCStd file to open. + + Returns: + Dictionary with opened document information: + - name: Internal document name + - label: Display label + - path: File path + - objects: List of object names + + Raises: + FileNotFoundError: If the file doesn't exist. + ValueError: If the file is not a valid FreeCAD document. + """ + bridge = await get_bridge() + doc = await bridge.open_document(path) + return { + "name": doc.name, + "label": doc.label, + "path": doc.path, + "objects": doc.objects, + } + + @mcp.tool() + async def save_document( + doc_name: str | None = None, + path: str | None = None, + ) -> dict[str, Any]: + """Save a FreeCAD document. + + Args: + doc_name: Name of document to save. Uses active document if None. + path: File path to save to. Uses existing path if None. + Required for new (unsaved) documents. + + Returns: + Dictionary with save result: + - success: Whether save was successful + - path: Path where document was saved + + Raises: + ValueError: If document not found or no path specified for new doc. + """ + bridge = await get_bridge() + saved_path = await bridge.save_document(doc_name, path) + return { + "success": True, + "path": saved_path, + } + + @mcp.tool() + async def close_document( + doc_name: str | None = None, + save_changes: bool = False, + ) -> dict[str, Any]: + """Close a FreeCAD document. + + Args: + doc_name: Name of document to close. Uses active document if None. + save_changes: Whether to save changes before closing. Defaults to False. + + Returns: + Dictionary with close result: + - success: Whether close was successful + - saved: Whether document was saved before closing + """ + bridge = await get_bridge() + + saved = False + if save_changes: + try: + await bridge.save_document(doc_name) + saved = True + except Exception: + pass + + await bridge.close_document(doc_name) + return { + "success": True, + "saved": saved, + } + + @mcp.tool() + async def recompute_document(doc_name: str | None = None) -> dict[str, Any]: + """Recompute a FreeCAD document to update all dependent objects. + + Args: + doc_name: Name of document to recompute. Uses active document if None. + + Returns: + Dictionary with recompute result: + - success: Whether recompute was successful + """ + bridge = await get_bridge() + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") +doc.recompute() +_result_ = True +""" + result = await bridge.execute_python(code) + return { + "success": result.success, + "error": result.error_traceback if not result.success else None, + } diff --git a/src/freecad_mcp/tools/execution.py b/src/freecad_mcp/tools/execution.py new file mode 100644 index 0000000..6c5f534 --- /dev/null +++ b/src/freecad_mcp/tools/execution.py @@ -0,0 +1,216 @@ +"""Execution tools for FreeCAD MCP server. + +This module provides tools for executing Python code in FreeCAD's context, +getting version information, and accessing the console. +""" + +import os +import platform +import socket +from pathlib import Path +from typing import Any + + +def register_execution_tools(mcp, get_bridge) -> None: + """Register execution-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def execute_python( + code: str, + timeout_ms: int = 30000, + ) -> dict[str, Any]: + """Execute Python code in FreeCAD's Python console context. + + This tool allows you to run arbitrary Python code within FreeCAD's + environment, with access to all FreeCAD modules and the active document. + + Args: + code: Python code to execute. Use `_result_ = value` to return data + to the caller. The code has access to FreeCAD, App, FreeCADGui, + and Gui modules. + timeout_ms: Maximum execution time in milliseconds. Defaults to 30000. + + Returns: + Dictionary containing execution results: + - success: Whether execution completed without errors + - result: The value assigned to `_result_` variable + - stdout: Captured standard output + - stderr: Captured standard error + - execution_time_ms: Time taken in milliseconds + - error_type: Type of exception if failed (None if success) + - error_traceback: Full traceback if failed (None if success) + + Example: + Create a simple box and return its volume:: + + execute_python(''' + import Part + box = Part.makeBox(10, 20, 30) + _result_ = {"volume": box.Volume, "area": box.Area} + ''') + + List all objects in the active document:: + + execute_python(''' + doc = FreeCAD.ActiveDocument + if doc: + _result_ = [obj.Name for obj in doc.Objects] + else: + _result_ = [] + ''') + """ + bridge = await get_bridge() + result = await bridge.execute_python(code, timeout_ms) + return { + "success": result.success, + "result": result.result, + "stdout": result.stdout, + "stderr": result.stderr, + "execution_time_ms": result.execution_time_ms, + "error_type": result.error_type, + "error_traceback": result.error_traceback, + } + + @mcp.tool() + async def get_freecad_version() -> dict[str, Any]: + """Get FreeCAD version and build information. + + Returns: + Dictionary containing version information: + - version: Version string (e.g., "0.21.2") + - version_tuple: Version as list of integers + - build_date: Build date string + - python_version: Embedded Python version + - gui_available: Whether GUI is available + """ + bridge = await get_bridge() + return await bridge.get_freecad_version() + + @mcp.tool() + async def get_connection_status() -> dict[str, Any]: + """Get the current FreeCAD connection status. + + Returns: + Dictionary containing connection information: + - connected: Whether bridge is connected + - mode: Connection mode (embedded, xmlrpc, socket) + - freecad_version: FreeCAD version string + - gui_available: Whether GUI is available + - last_ping_ms: Last ping latency in milliseconds + - error: Error message if not connected + """ + bridge = await get_bridge() + status = await bridge.get_status() + return { + "connected": status.connected, + "mode": status.mode, + "freecad_version": status.freecad_version, + "gui_available": status.gui_available, + "last_ping_ms": status.last_ping_ms, + "error": status.error, + } + + @mcp.tool() + async def get_console_output(lines: int = 100) -> list[str]: + """Get recent FreeCAD console output. + + Args: + lines: Maximum number of lines to return. Defaults to 100. + + Returns: + List of console output lines, most recent last. + """ + bridge = await get_bridge() + return await bridge.get_console_output(lines) + + @mcp.tool() + async def get_mcp_server_environment() -> dict[str, Any]: + """Get environment information about the MCP server process. + + This tool returns information about the environment where the MCP server + is running, which is useful for debugging and verifying which MCP server + instance you are connected to (e.g., host vs Docker container). + + Returns: + Dictionary containing environment information: + - hostname: Machine hostname + - os_name: Operating system name (Linux, Darwin, Windows) + - os_version: Operating system version + - platform: Platform identifier string + - python_version: Python version running the MCP server + - in_docker: Whether running inside a Docker container + - docker_container_id: Container ID if in Docker (first 12 chars) + - env_vars: Selected environment variables for debugging: + - FREECAD_MODE: Connection mode + - FREECAD_SOCKET_HOST: Socket host + - FREECAD_SOCKET_PORT: Socket port + - FREECAD_XMLRPC_PORT: XML-RPC port + + Example: + Verify you're talking to the containerized MCP server:: + + env = get_mcp_server_environment() + if env["in_docker"]: + print(f"Connected to container: {env['docker_container_id']}") + else: + print("Connected to host MCP server") + """ + + def _detect_docker() -> tuple[bool, str | None]: + """Detect if running inside Docker and get container ID.""" + # Check for .dockerenv file (most reliable) + dockerenv = Path("/.dockerenv") + if dockerenv.exists(): + # Try to get container ID from cgroup + container_id = None + try: + cgroup_path = Path("/proc/self/cgroup") + with cgroup_path.open() as f: + for line in f: + if "docker" in line or "containerd" in line: + # Extract container ID from path + parts = line.strip().split("/") + if parts: + cid = parts[-1] + # Container IDs are 64 hex chars + if len(cid) >= 12: + container_id = cid[:12] + break + except (OSError, IndexError): + pass + return True, container_id + + # Also check cgroup for containerized environments + try: + cgroup_init = Path("/proc/1/cgroup") + with cgroup_init.open() as f: + content = f.read() + if "docker" in content or "containerd" in content: + return True, None + except OSError: + pass + + return False, None + + in_docker, container_id = _detect_docker() + + return { + "hostname": socket.gethostname(), + "os_name": platform.system(), + "os_version": platform.release(), + "platform": platform.platform(), + "python_version": platform.python_version(), + "in_docker": in_docker, + "docker_container_id": container_id, + "env_vars": { + "FREECAD_MODE": os.environ.get("FREECAD_MODE", ""), + "FREECAD_SOCKET_HOST": os.environ.get("FREECAD_SOCKET_HOST", ""), + "FREECAD_SOCKET_PORT": os.environ.get("FREECAD_SOCKET_PORT", ""), + "FREECAD_XMLRPC_PORT": os.environ.get("FREECAD_XMLRPC_PORT", ""), + }, + } diff --git a/src/freecad_mcp/tools/export.py b/src/freecad_mcp/tools/export.py new file mode 100644 index 0000000..8985152 --- /dev/null +++ b/src/freecad_mcp/tools/export.py @@ -0,0 +1,458 @@ +"""Export tools for FreeCAD MCP server. + +This module provides tools for exporting FreeCAD documents and objects +to various file formats: STEP, STL, 3MF, OBJ, IGES, and FreeCAD native. +""" + +from typing import Any + + +def register_export_tools(mcp, get_bridge) -> None: + """Register export-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def export_step( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Export objects to STEP format. + + STEP (Standard for the Exchange of Product Data) is an ISO standard + for CAD data exchange, widely supported by CAD software. + + Args: + file_path: Path for the output .step file. + object_names: List of object names to export. Exports all visible if None. + doc_name: Document to export from. Uses active document if None. + + Returns: + Dictionary with export result: + - success: Whether export was successful + - path: Path to exported file + - object_count: Number of objects exported + """ + bridge = await get_bridge() + + objects_filter = ( + f"[doc.getObject(n) for n in {object_names!r}]" + if object_names + else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]" + ) + + code = f""" +import Part + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = {objects_filter} +objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')] + +if not objects: + raise ValueError("No exportable objects found") + +# Combine shapes +if len(objects) == 1: + shape = objects[0].Shape +else: + shape = Part.makeCompound([obj.Shape for obj in objects]) + +shape.exportStep({file_path!r}) + +_result_ = {{ + "success": True, + "path": {file_path!r}, + "object_count": len(objects), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "STEP export failed") + + @mcp.tool() + async def export_stl( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + mesh_tolerance: float = 0.1, + ) -> dict[str, Any]: + """Export objects to STL format. + + STL (Stereolithography) is commonly used for 3D printing and + rapid prototyping. It represents surfaces as triangular meshes. + + Args: + file_path: Path for the output .stl file. + object_names: List of object names to export. Exports all visible if None. + doc_name: Document to export from. Uses active document if None. + mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh. + + Returns: + Dictionary with export result: + - success: Whether export was successful + - path: Path to exported file + - object_count: Number of objects exported + """ + bridge = await get_bridge() + + objects_filter = ( + f"[doc.getObject(n) for n in {object_names!r}]" + if object_names + else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]" + ) + + code = f""" +import Mesh +import Part + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = {objects_filter} +objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')] + +if not objects: + raise ValueError("No exportable objects found") + +# Create mesh from shapes +meshes = [] +for obj in objects: + mesh = Mesh.Mesh() + mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0]) + meshes.append(mesh) + +# Combine meshes +if len(meshes) == 1: + final_mesh = meshes[0] +else: + final_mesh = Mesh.Mesh() + for m in meshes: + final_mesh.addMesh(m) + +final_mesh.write({file_path!r}) + +_result_ = {{ + "success": True, + "path": {file_path!r}, + "object_count": len(objects), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "STL export failed") + + @mcp.tool() + async def export_3mf( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + mesh_tolerance: float = 0.1, + ) -> dict[str, Any]: + """Export objects to 3MF format. + + 3MF (3D Manufacturing Format) is a modern 3D printing format that + supports richer data than STL, including colors, materials, and + print settings. It is increasingly preferred over STL for 3D printing. + + Args: + file_path: Path for the output .3mf file. + object_names: List of object names to export. Exports all visible if None. + doc_name: Document to export from. Uses active document if None. + mesh_tolerance: Mesh approximation tolerance. Lower = finer mesh. + + Returns: + Dictionary with export result: + - success: Whether export was successful + - path: Path to exported file + - object_count: Number of objects exported + """ + bridge = await get_bridge() + + objects_filter = ( + f"[doc.getObject(n) for n in {object_names!r}]" + if object_names + else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]" + ) + + code = f""" +import Mesh +import Part + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = {objects_filter} +objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')] + +if not objects: + raise ValueError("No exportable objects found") + +# Create mesh from shapes +meshes = [] +for obj in objects: + mesh = Mesh.Mesh() + mesh.addFacets(obj.Shape.tessellate({mesh_tolerance})[0]) + meshes.append(mesh) + +# Combine meshes +if len(meshes) == 1: + final_mesh = meshes[0] +else: + final_mesh = Mesh.Mesh() + for m in meshes: + final_mesh.addMesh(m) + +# Export to 3MF format +final_mesh.write({file_path!r}) + +_result_ = {{ + "success": True, + "path": {file_path!r}, + "object_count": len(objects), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "3MF export failed") + + @mcp.tool() + async def export_obj( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Export objects to OBJ format. + + OBJ (Wavefront) is a common 3D model format supported by many + 3D graphics applications and game engines. + + Args: + file_path: Path for the output .obj file. + object_names: List of object names to export. Exports all visible if None. + doc_name: Document to export from. Uses active document if None. + + Returns: + Dictionary with export result: + - success: Whether export was successful + - path: Path to exported file + - object_count: Number of objects exported + """ + bridge = await get_bridge() + + objects_filter = ( + f"[doc.getObject(n) for n in {object_names!r}]" + if object_names + else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]" + ) + + code = f""" +import Mesh + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = {objects_filter} +objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')] + +if not objects: + raise ValueError("No exportable objects found") + +# Create mesh from shapes +meshes = [] +for obj in objects: + mesh = Mesh.Mesh() + mesh.addFacets(obj.Shape.tessellate(0.1)[0]) + meshes.append(mesh) + +# Combine meshes +if len(meshes) == 1: + final_mesh = meshes[0] +else: + final_mesh = Mesh.Mesh() + for m in meshes: + final_mesh.addMesh(m) + +final_mesh.write({file_path!r}) + +_result_ = {{ + "success": True, + "path": {file_path!r}, + "object_count": len(objects), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "OBJ export failed") + + @mcp.tool() + async def export_iges( + file_path: str, + object_names: list[str] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Export objects to IGES format. + + IGES (Initial Graphics Exchange Specification) is an older but still + widely supported CAD data exchange format. + + Args: + file_path: Path for the output .iges file. + object_names: List of object names to export. Exports all visible if None. + doc_name: Document to export from. Uses active document if None. + + Returns: + Dictionary with export result: + - success: Whether export was successful + - path: Path to exported file + - object_count: Number of objects exported + """ + bridge = await get_bridge() + + objects_filter = ( + f"[doc.getObject(n) for n in {object_names!r}]" + if object_names + else "[obj for obj in doc.Objects if hasattr(obj, 'Shape') and obj.ViewObject.Visibility]" + ) + + code = f""" +import Part + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +objects = {objects_filter} +objects = [obj for obj in objects if obj is not None and hasattr(obj, 'Shape')] + +if not objects: + raise ValueError("No exportable objects found") + +# Combine shapes +if len(objects) == 1: + shape = objects[0].Shape +else: + shape = Part.makeCompound([obj.Shape for obj in objects]) + +shape.exportIges({file_path!r}) + +_result_ = {{ + "success": True, + "path": {file_path!r}, + "object_count": len(objects), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "IGES export failed") + + @mcp.tool() + async def import_step( + file_path: str, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Import a STEP file into FreeCAD. + + Args: + file_path: Path to the .step file to import. + doc_name: Document to import into. Creates new if None. + + Returns: + Dictionary with import result: + - success: Whether import was successful + - document: Name of document containing imported objects + - objects: List of imported object names + """ + bridge = await get_bridge() + + code = f""" +import Part +import os + +if not os.path.exists({file_path!r}): + raise FileNotFoundError(f"File not found: {file_path!r}") + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + doc = FreeCAD.newDocument("Imported") + +# Get object count before import +before_count = len(doc.Objects) + +Part.insert({file_path!r}, doc.Name) +doc.recompute() + +# Get new objects +new_objects = [obj.Name for obj in doc.Objects[before_count:]] + +_result_ = {{ + "success": True, + "document": doc.Name, + "objects": new_objects, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "STEP import failed") + + @mcp.tool() + async def import_stl( + file_path: str, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Import an STL file into FreeCAD. + + Args: + file_path: Path to the .stl file to import. + doc_name: Document to import into. Creates new if None. + + Returns: + Dictionary with import result: + - success: Whether import was successful + - document: Name of document containing imported object + - object: Name of imported mesh object + """ + bridge = await get_bridge() + + code = f""" +import Mesh +import os + +if not os.path.exists({file_path!r}): + raise FileNotFoundError(f"File not found: {file_path!r}") + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + doc = FreeCAD.newDocument("Imported") + +Mesh.insert({file_path!r}, doc.Name) +doc.recompute() + +# Get the last added object (the imported mesh) +mesh_obj = doc.Objects[-1] + +_result_ = {{ + "success": True, + "document": doc.Name, + "object": mesh_obj.Name, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "STL import failed") diff --git a/src/freecad_mcp/tools/macros.py b/src/freecad_mcp/tools/macros.py new file mode 100644 index 0000000..033f2f4 --- /dev/null +++ b/src/freecad_mcp/tools/macros.py @@ -0,0 +1,330 @@ +"""Macro management tools for FreeCAD MCP server. + +This module provides tools for managing FreeCAD macros: +listing, running, creating, and editing macros. + +Based on learnings from ATOI-Ming/FreeCAD-MCP which has a +macro-centric workflow with templates and validation. +""" + +from typing import Any + + +def register_macro_tools(mcp, get_bridge) -> None: + """Register macro-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def list_macros() -> list[dict[str, Any]]: + """List all available FreeCAD macros. + + Returns: + List of dictionaries, each containing: + - name: Macro name (without extension) + - path: Full path to macro file + - description: Macro description from comments + - is_system: Whether it's a system macro + """ + bridge = await get_bridge() + macros = await bridge.get_macros() + return [ + { + "name": macro.name, + "path": macro.path, + "description": macro.description, + "is_system": macro.is_system, + } + for macro in macros + ] + + @mcp.tool() + async def run_macro( + macro_name: str, + args: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Run a FreeCAD macro by name. + + Args: + macro_name: Name of the macro to run (without .FCMacro extension). + args: Optional dictionary of arguments to pass to the macro. + These will be set as variables before execution. + + Returns: + Dictionary with execution result: + - success: Whether macro executed successfully + - stdout: Captured standard output + - stderr: Captured standard error + - execution_time_ms: Execution time in milliseconds + - error_type: Error type if failed + - error_traceback: Full traceback if failed + """ + bridge = await get_bridge() + result = await bridge.run_macro(macro_name, args) + return { + "success": result.success, + "stdout": result.stdout, + "stderr": result.stderr, + "execution_time_ms": result.execution_time_ms, + "error_type": result.error_type, + "error_traceback": result.error_traceback, + } + + @mcp.tool() + async def create_macro( + name: str, + code: str, + description: str = "", + ) -> dict[str, Any]: + """Create a new FreeCAD macro. + + The macro will be created in the user's macro directory with + standard FreeCAD imports automatically added. + + Args: + name: Macro name (without .FCMacro extension). + code: Python code for the macro body. + description: Optional description for the macro. + + Returns: + Dictionary with created macro information: + - name: Macro name + - path: Full path to created macro file + - description: Macro description + """ + bridge = await get_bridge() + macro = await bridge.create_macro(name, code, description) + return { + "name": macro.name, + "path": macro.path, + "description": macro.description, + } + + @mcp.tool() + async def read_macro(macro_name: str) -> dict[str, Any]: + """Read the contents of a FreeCAD macro. + + Args: + macro_name: Name of the macro to read (without .FCMacro extension). + + Returns: + Dictionary with macro contents: + - name: Macro name + - code: Full macro code + - path: Path to macro file + """ + bridge = await get_bridge() + + code = f""" +import os + +# Find macro file +macro_name = {macro_name!r} +macro_file = None + +user_path = FreeCAD.getUserMacroDir(True) +user_macro = os.path.join(user_path, macro_name + ".FCMacro") +if os.path.exists(user_macro): + macro_file = user_macro + +if not macro_file: + system_path = FreeCAD.getResourceDir() + "Macro" + system_macro = os.path.join(system_path, macro_name + ".FCMacro") + if os.path.exists(system_macro): + macro_file = system_macro + +if not macro_file: + raise FileNotFoundError(f"Macro not found: {{macro_name}}") + +with open(macro_file, "r") as f: + macro_code = f.read() + +_result_ = {{ + "name": macro_name, + "code": macro_code, + "path": macro_file, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Read macro failed") + + @mcp.tool() + async def delete_macro(macro_name: str) -> dict[str, Any]: + """Delete a user macro. + + Note: Only user macros can be deleted. System macros are protected. + + Args: + macro_name: Name of the macro to delete (without .FCMacro extension). + + Returns: + Dictionary with delete result: + - success: Whether deletion was successful + - path: Path of deleted macro + """ + bridge = await get_bridge() + + code = f""" +import os + +macro_name = {macro_name!r} +user_path = FreeCAD.getUserMacroDir(True) +macro_file = os.path.join(user_path, macro_name + ".FCMacro") + +if not os.path.exists(macro_file): + raise FileNotFoundError(f"User macro not found: {{macro_name}}") + +os.remove(macro_file) + +_result_ = {{ + "success": True, + "path": macro_file, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Delete macro failed") + + @mcp.tool() + async def create_macro_from_template( + name: str, + template: str = "basic", + description: str = "", + ) -> dict[str, Any]: + """Create a new macro from a predefined template. + + Available templates provide common starting points for FreeCAD macros. + + Args: + name: Macro name (without .FCMacro extension). + template: Template name. Options: + - "basic" - Basic template with imports + - "part" - Part workbench operations template + - "sketch" - Sketcher operations template + - "gui" - GUI/dialog template + - "selection" - Selection handling template + description: Optional description for the macro. + + Returns: + Dictionary with created macro information: + - name: Macro name + - path: Full path to created macro file + - template: Template used + """ + templates = { + "basic": """ +# Your macro code goes here +doc = FreeCAD.ActiveDocument +if doc is None: + doc = FreeCAD.newDocument("MacroDoc") + +# Add your operations here +print("Macro executed successfully!") +""", + "part": """ +import Part + +doc = FreeCAD.ActiveDocument +if doc is None: + doc = FreeCAD.newDocument("MacroDoc") + +# Create a box as example +box = doc.addObject("Part::Box", "MyBox") +box.Length = 10 +box.Width = 20 +box.Height = 30 + +doc.recompute() +print(f"Created box with volume: {box.Shape.Volume}") +""", + "sketch": """ +import Part +import Sketcher + +doc = FreeCAD.ActiveDocument +if doc is None: + doc = FreeCAD.newDocument("MacroDoc") + +# Create a sketch +sketch = doc.addObject("Sketcher::SketchObject", "MySketch") + +# Add a rectangle +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 0, 0), FreeCAD.Vector(10, 0, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 0, 0), FreeCAD.Vector(10, 10, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(10, 10, 0), FreeCAD.Vector(0, 10, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(0, 10, 0), FreeCAD.Vector(0, 0, 0)), False) + +doc.recompute() +print("Created sketch with rectangle") +""", + "gui": """ +from PySide2 import QtWidgets + +class MacroDialog(QtWidgets.QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Macro Dialog") + self.setup_ui() + + def setup_ui(self): + layout = QtWidgets.QVBoxLayout() + + self.label = QtWidgets.QLabel("Enter value:") + layout.addWidget(self.label) + + self.input = QtWidgets.QLineEdit() + layout.addWidget(self.input) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.setLayout(layout) + +# Show dialog +dialog = MacroDialog() +if dialog.exec_(): + value = dialog.input.text() + print(f"User entered: {value}") +""", + "selection": """ +# Get current selection +sel = FreeCADGui.Selection.getSelection() + +if not sel: + print("Nothing selected!") +else: + for obj in sel: + print(f"Selected: {obj.Name} ({obj.TypeId})") + + # Access shape if available + if hasattr(obj, "Shape"): + shape = obj.Shape + print(f" - Volume: {shape.Volume}") + print(f" - Area: {shape.Area}") + print(f" - Faces: {len(shape.Faces)}") + print(f" - Edges: {len(shape.Edges)}") +""", + } + + if template not in templates: + raise ValueError( + f"Unknown template: {template}. Available: {list(templates.keys())}" + ) + + bridge = await get_bridge() + macro = await bridge.create_macro(name, templates[template], description) + return { + "name": macro.name, + "path": macro.path, + "template": template, + } diff --git a/src/freecad_mcp/tools/objects.py b/src/freecad_mcp/tools/objects.py new file mode 100644 index 0000000..da77dc4 --- /dev/null +++ b/src/freecad_mcp/tools/objects.py @@ -0,0 +1,993 @@ +"""Object management tools for FreeCAD MCP server. + +This module provides tools for managing FreeCAD objects: +creating, editing, deleting, and inspecting objects. +""" + +from typing import Any + + +def register_object_tools(mcp, get_bridge) -> None: + """Register object-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def list_objects(doc_name: str | None = None) -> list[dict[str, Any]]: + """List all objects in a FreeCAD document. + + Args: + doc_name: Name of document. Uses active document if None. + + Returns: + List of dictionaries, each containing: + - name: Object name + - label: Display label + - type_id: FreeCAD type identifier (e.g., "Part::Box") + - visibility: Whether object is visible + """ + bridge = await get_bridge() + objects = await bridge.get_objects(doc_name) + return [ + { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + "visibility": obj.visibility, + } + for obj in objects + ] + + @mcp.tool() + async def inspect_object( + object_name: str, + doc_name: str | None = None, + include_properties: bool = True, + include_shape: bool = True, + ) -> dict[str, Any]: + """Get detailed information about a FreeCAD object. + + Args: + object_name: Name of the object to inspect. + doc_name: Document containing the object. Uses active document if None. + include_properties: Whether to include property values. + include_shape: Whether to include shape geometry details. + + Returns: + Dictionary containing comprehensive object information: + - name: Object name + - label: Object label + - type_id: FreeCAD type identifier + - properties: Dictionary of property names and values (if requested) + - shape_info: Shape details (if requested and object has shape) + - children: List of child object names + - parents: List of parent object names + - visibility: Whether object is visible + """ + bridge = await get_bridge() + obj = await bridge.get_object(object_name, doc_name) + + result = { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + "children": obj.children, + "parents": obj.parents, + "visibility": obj.visibility, + } + + if include_properties: + result["properties"] = obj.properties + + if include_shape and obj.shape_info: + result["shape_info"] = obj.shape_info + + return result + + @mcp.tool() + async def create_object( + type_id: str, + name: str | None = None, + properties: dict[str, Any] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a new FreeCAD object. + + Args: + type_id: FreeCAD type ID for the object. Common types include: + - "Part::Box" - Parametric box + - "Part::Cylinder" - Parametric cylinder + - "Part::Sphere" - Parametric sphere + - "Part::Cone" - Parametric cone + - "Part::Torus" - Parametric torus + - "Part::Feature" - Generic Part feature + - "Sketcher::SketchObject" - Sketch + - "PartDesign::Body" - PartDesign body + name: Object name. Auto-generated if None. + properties: Initial property values to set. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object(type_id, name, properties, doc_name) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_box( + length: float = 10.0, + width: float = 10.0, + height: float = 10.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Box primitive. + + Args: + length: Box length (X dimension). Defaults to 10.0. + width: Box width (Y dimension). Defaults to 10.0. + height: Box height (Z dimension). Defaults to 10.0. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - volume: Box volume + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Box", + name, + {"Length": length, "Width": width, "Height": height}, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + "volume": length * width * height, + } + + @mcp.tool() + async def create_cylinder( + radius: float = 5.0, + height: float = 10.0, + angle: float = 360.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Cylinder primitive. + + Args: + radius: Cylinder radius. Defaults to 5.0. + height: Cylinder height. Defaults to 10.0. + angle: Sweep angle in degrees (for partial cylinder). Defaults to 360. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Cylinder", + name, + {"Radius": radius, "Height": height, "Angle": angle}, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_sphere( + radius: float = 5.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Sphere primitive. + + Args: + radius: Sphere radius. Defaults to 5.0. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Sphere", + name, + {"Radius": radius}, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_cone( + radius1: float = 5.0, + radius2: float = 0.0, + height: float = 10.0, + angle: float = 360.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Cone primitive. + + Args: + radius1: Bottom radius. Defaults to 5.0. + radius2: Top radius (0 for pointed cone). Defaults to 0.0. + height: Cone height. Defaults to 10.0. + angle: Sweep angle in degrees (for partial cone). Defaults to 360. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Cone", + name, + {"Radius1": radius1, "Radius2": radius2, "Height": height, "Angle": angle}, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_torus( + radius1: float = 10.0, + radius2: float = 2.0, + angle1: float = -180.0, + angle2: float = 180.0, + angle3: float = 360.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Torus (donut shape) primitive. + + Args: + radius1: Major radius (center to tube center). Defaults to 10.0. + radius2: Minor radius (tube radius). Defaults to 2.0. + angle1: Start angle for tube sweep. Defaults to -180. + angle2: End angle for tube sweep. Defaults to 180. + angle3: Rotation angle around axis. Defaults to 360. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Torus", + name, + { + "Radius1": radius1, + "Radius2": radius2, + "Angle1": angle1, + "Angle2": angle2, + "Angle3": angle3, + }, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_wedge( + xmin: float = 0.0, + ymin: float = 0.0, + zmin: float = 0.0, + x2min: float = 2.0, + z2min: float = 2.0, + xmax: float = 10.0, + ymax: float = 10.0, + zmax: float = 10.0, + x2max: float = 8.0, + z2max: float = 8.0, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Wedge primitive. + + A wedge is a tapered box shape useful for ramps and similar geometry. + + Args: + xmin: Minimum X at base. Defaults to 0.0. + ymin: Minimum Y (base position). Defaults to 0.0. + zmin: Minimum Z at base. Defaults to 0.0. + x2min: Minimum X at top. Defaults to 2.0. + z2min: Minimum Z at top. Defaults to 2.0. + xmax: Maximum X at base. Defaults to 10.0. + ymax: Maximum Y (top position). Defaults to 10.0. + zmax: Maximum Z at base. Defaults to 10.0. + x2max: Maximum X at top. Defaults to 8.0. + z2max: Maximum Z at top. Defaults to 8.0. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Wedge", + name, + { + "Xmin": xmin, + "Ymin": ymin, + "Zmin": zmin, + "X2min": x2min, + "Z2min": z2min, + "Xmax": xmax, + "Ymax": ymax, + "Zmax": zmax, + "X2max": x2max, + "Z2max": z2max, + }, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_helix( + pitch: float = 5.0, + height: float = 20.0, + radius: float = 5.0, + angle: float = 0.0, + left_handed: bool = False, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Part Helix curve. + + A helix is a spiral curve, useful as a sweep path for threads and springs. + + Args: + pitch: Distance between turns. Defaults to 5.0. + height: Total helix height. Defaults to 20.0. + radius: Helix radius. Defaults to 5.0. + angle: Taper angle in degrees. Defaults to 0.0. + left_handed: Whether helix is left-handed. Defaults to False. + name: Object name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object( + "Part::Helix", + name, + { + "Pitch": pitch, + "Height": height, + "Radius": radius, + "Angle": angle, + "LocalCoord": 1 if left_handed else 0, + }, + doc_name, + ) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def edit_object( + object_name: str, + properties: dict[str, Any], + doc_name: str | None = None, + ) -> dict[str, Any]: + """Edit properties of an existing FreeCAD object. + + Args: + object_name: Name of the object to edit. + properties: Dictionary of property names and new values. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with updated object information: + - name: Object name + - label: Object label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.edit_object(object_name, properties, doc_name) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def delete_object( + object_name: str, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Delete an object from a FreeCAD document. + + Args: + object_name: Name of the object to delete. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with delete result: + - success: Whether delete was successful + """ + bridge = await get_bridge() + await bridge.delete_object(object_name, doc_name) + return {"success": True} + + @mcp.tool() + async def boolean_operation( + operation: str, + object1_name: str, + object2_name: str, + result_name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Perform a boolean operation on two FreeCAD objects. + + Args: + operation: Boolean operation type: "fuse" (union), "cut" (subtract), + or "common" (intersection). + object1_name: Name of the first object. + object2_name: Name of the second object. + result_name: Name for the result object. Auto-generated if None. + doc_name: Document containing the objects. Uses active document if None. + + Returns: + Dictionary with result object information: + - name: Result object name + - label: Result object label + - type_id: Result object type + """ + bridge = await get_bridge() + + operation_map = { + "fuse": "Part::MultiFuse", + "cut": "Part::Cut", + "common": "Part::MultiCommon", + } + + if operation not in operation_map: + raise ValueError(f"Invalid operation: {operation}. Use: fuse, cut, common") + + op_type = operation_map[operation] + result_name = result_name or f"{operation.capitalize()}" + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj1 = doc.getObject({object1_name!r}) +obj2 = doc.getObject({object2_name!r}) + +if obj1 is None: + raise ValueError(f"Object not found: {object1_name!r}") +if obj2 is None: + raise ValueError(f"Object not found: {object2_name!r}") + +if {op_type!r} == "Part::Cut": + result = doc.addObject({op_type!r}, {result_name!r}) + result.Base = obj1 + result.Tool = obj2 +else: + result = doc.addObject({op_type!r}, {result_name!r}) + result.Shapes = [obj1, obj2] + +doc.recompute() + +_result_ = {{ + "name": result.Name, + "label": result.Label, + "type_id": result.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Boolean operation failed") + + @mcp.tool() + async def set_placement( + object_name: str, + position: list[float] | None = None, + rotation: list[float] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the placement (position and rotation) of a FreeCAD object. + + Args: + object_name: Name of the object to move. + position: Position as [x, y, z]. Keeps current if None. + rotation: Rotation as [yaw, pitch, roll] in degrees. Keeps current if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with new placement: + - position: New position [x, y, z] + - rotation: New rotation angles + """ + bridge = await get_bridge() + + pos_str = ( + f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})" + if position + else "obj.Placement.Base" + ) + rot_str = ( + f"FreeCAD.Rotation({rotation[0]}, {rotation[1]}, {rotation[2]})" + if rotation + else "obj.Placement.Rotation" + ) + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +pos = {pos_str} +rot = {rot_str} + +obj.Placement = FreeCAD.Placement(pos, rot) +doc.recompute() + +_result_ = {{ + "position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z], + "rotation": list(obj.Placement.Rotation.toEuler()), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Set placement failed") + + @mcp.tool() + async def scale_object( + object_name: str, + scale: float | list[float], + result_name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Scale an object uniformly or non-uniformly. + + Creates a new scaled copy using Part.Scale. + + Args: + object_name: Name of the object to scale. + scale: Scale factor. Can be: + - A single float for uniform scaling + - A list [sx, sy, sz] for non-uniform scaling + result_name: Name for the result object. Auto-generated if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with result object information: + - name: Result object name + - label: Result object label + - type_id: Result object type + """ + bridge = await get_bridge() + + if isinstance(scale, int | float): + scale_vec = f"FreeCAD.Vector({scale}, {scale}, {scale})" + else: + scale_vec = f"FreeCAD.Vector({scale[0]}, {scale[1]}, {scale[2]})" + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +if not hasattr(obj, "Shape"): + raise ValueError("Object has no shape to scale") + +import Part + +scale_vec = {scale_vec} +center = obj.Shape.BoundBox.Center + +# Create scaled shape +mat = FreeCAD.Matrix() +mat.scale(scale_vec) +scaled_shape = obj.Shape.transformGeometry(mat) + +# Create result object +result_name = {result_name!r} or f"{{obj.Name}}_scaled" +result = doc.addObject("Part::Feature", result_name) +result.Shape = scaled_shape + +doc.recompute() + +_result_ = {{ + "name": result.Name, + "label": result.Label, + "type_id": result.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Scale operation failed") + + @mcp.tool() + async def rotate_object( + object_name: str, + axis: list[float], + angle: float, + center: list[float] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Rotate an object around an axis. + + Modifies the object's placement in-place. + + Args: + object_name: Name of the object to rotate. + axis: Rotation axis as [x, y, z] vector. + angle: Rotation angle in degrees. + center: Center point for rotation [x, y, z]. + Uses object center if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with new placement: + - position: New position [x, y, z] + - rotation: New rotation angles + """ + bridge = await get_bridge() + + center_str = ( + f"FreeCAD.Vector({center[0]}, {center[1]}, {center[2]})" + if center + else "obj.Shape.BoundBox.Center if hasattr(obj, 'Shape') else FreeCAD.Vector(0,0,0)" + ) + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +axis = FreeCAD.Vector({axis[0]}, {axis[1]}, {axis[2]}) +center = {center_str} + +# Create rotation +rot = FreeCAD.Rotation(axis, {angle}) + +# Apply rotation around center +old_placement = obj.Placement +new_rot = rot.multiply(old_placement.Rotation) + +# Adjust position for rotation around center +pos_vec = old_placement.Base - center +rotated_pos = rot.multVec(pos_vec) + center + +obj.Placement = FreeCAD.Placement(rotated_pos, new_rot) +doc.recompute() + +_result_ = {{ + "position": [obj.Placement.Base.x, obj.Placement.Base.y, obj.Placement.Base.z], + "rotation": list(obj.Placement.Rotation.toEuler()), +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Rotate operation failed") + + @mcp.tool() + async def copy_object( + object_name: str, + new_name: str | None = None, + offset: list[float] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a copy of an object. + + Args: + object_name: Name of the object to copy. + new_name: Name for the copy. Auto-generated if None. + offset: Position offset [x, y, z] for the copy. [0,0,0] if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with copy object information: + - name: Copy object name + - label: Copy object label + - type_id: Copy object type + """ + bridge = await get_bridge() + + offset_str = ( + f"[{offset[0]}, {offset[1]}, {offset[2]}]" if offset else "[0, 0, 0]" + ) + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +# Create copy +new_name = {new_name!r} or f"{{obj.Name}}_copy" + +if hasattr(obj, "Shape"): + copy_obj = doc.addObject("Part::Feature", new_name) + copy_obj.Shape = obj.Shape.copy() +else: + # For non-shape objects, create simple copy + copy_obj = doc.copyObject(obj, False) + copy_obj.Label = new_name + +# Apply offset +offset = {offset_str} +copy_obj.Placement.Base = FreeCAD.Vector( + obj.Placement.Base.x + offset[0], + obj.Placement.Base.y + offset[1], + obj.Placement.Base.z + offset[2] +) + +doc.recompute() + +_result_ = {{ + "name": copy_obj.Name, + "label": copy_obj.Label, + "type_id": copy_obj.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Copy operation failed") + + @mcp.tool() + async def mirror_object( + object_name: str, + plane: str = "XY", + result_name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Mirror an object across a plane. + + Creates a new mirrored copy of the object. + + Args: + object_name: Name of the object to mirror. + plane: Mirror plane. Options: "XY", "XZ", "YZ". + result_name: Name for the result object. Auto-generated if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with result object information: + - name: Result object name + - label: Result object label + - type_id: Result object type + """ + bridge = await get_bridge() + + plane_map = { + "XY": "(0, 0, 1)", + "XZ": "(0, 1, 0)", + "YZ": "(1, 0, 0)", + } + + if plane not in plane_map: + raise ValueError(f"Invalid plane: {plane}. Use: XY, XZ, YZ") + + normal = plane_map[plane] + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + raise ValueError("No document found") + +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +if not hasattr(obj, "Shape"): + raise ValueError("Object has no shape to mirror") + +# Create mirror matrix +import Part +normal = FreeCAD.Vector{normal} +center = obj.Shape.BoundBox.Center + +# Mirror the shape +mirrored = obj.Shape.mirror(center, normal) + +# Create result object +result_name = {result_name!r} or f"{{obj.Name}}_mirror" +result = doc.addObject("Part::Feature", result_name) +result.Shape = mirrored + +doc.recompute() + +_result_ = {{ + "name": result.Name, + "label": result.Label, + "type_id": result.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Mirror operation failed") + + @mcp.tool() + async def get_selection(doc_name: str | None = None) -> list[dict[str, Any]]: + """Get the current selection in FreeCAD. + + Requires GUI mode. + + Args: + doc_name: Document to check selection in. Uses active document if None. + + Returns: + List of selected objects with: + - name: Object name + - label: Object label + - type_id: Object type + - sub_elements: List of selected sub-elements (e.g., ["Face1", "Edge2"]) + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = [] +else: + sel = FreeCADGui.Selection.getSelectionEx({doc_name!r}) + _result_ = [] + for s in sel: + _result_.append({{ + "name": s.Object.Name, + "label": s.Object.Label, + "type_id": s.Object.TypeId, + "sub_elements": list(s.SubElementNames) if s.SubElementNames else [], + }}) +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + return [] + + @mcp.tool() + async def set_selection( + object_names: list[str], + clear_existing: bool = True, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the selection in FreeCAD. + + Requires GUI mode. + + Args: + object_names: List of object names to select. + clear_existing: Whether to clear existing selection first. Defaults to True. + doc_name: Document containing the objects. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + - selected_count: Number of objects selected + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + raise ValueError("No document found") + + if {clear_existing}: + FreeCADGui.Selection.clearSelection() + + count = 0 + for name in {object_names!r}: + obj = doc.getObject(name) + if obj: + FreeCADGui.Selection.addSelection(obj) + count += 1 + + _result_ = {{"success": True, "selected_count": count}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Set selection failed") + + @mcp.tool() + async def clear_selection() -> dict[str, Any]: + """Clear the current selection in FreeCAD. + + Requires GUI mode. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + bridge = await get_bridge() + + code = """ +if not FreeCAD.GuiUp: + _result_ = {"success": False, "error": "GUI not available"} +else: + FreeCADGui.Selection.clearSelection() + _result_ = {"success": True} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Clear selection failed") diff --git a/src/freecad_mcp/tools/partdesign.py b/src/freecad_mcp/tools/partdesign.py new file mode 100644 index 0000000..2f3aff4 --- /dev/null +++ b/src/freecad_mcp/tools/partdesign.py @@ -0,0 +1,1286 @@ +"""PartDesign tools for FreeCAD MCP server. + +This module provides tools for the PartDesign workbench, enabling +parametric solid modeling operations like Pad, Pocket, Fillet, etc. + +Based on learnings from contextform/freecad-mcp which has the most +comprehensive PartDesign coverage. +""" + +from typing import Any + + +def register_partdesign_tools(mcp, get_bridge) -> None: + """Register PartDesign-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def create_partdesign_body( + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a new PartDesign Body. + + A PartDesign Body is a container for feature-based modeling that + maintains a single solid shape through a sequence of operations. + + Args: + name: Body name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created body information: + - name: Body name + - label: Body label + - type_id: Object type + """ + bridge = await get_bridge() + obj = await bridge.create_object("PartDesign::Body", name, None, doc_name) + return { + "name": obj.name, + "label": obj.label, + "type_id": obj.type_id, + } + + @mcp.tool() + async def create_sketch( + body_name: str | None = None, + plane: str = "XY_Plane", + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a new Sketch attached to a plane or body. + + Args: + body_name: Name of PartDesign Body to attach to. Creates standalone if None. + plane: Plane to attach sketch to. Options: + - "XY_Plane" - Horizontal plane + - "XZ_Plane" - Front vertical plane + - "YZ_Plane" - Side vertical plane + - Face name like "Face1" to attach to body face + name: Sketch name. Auto-generated if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with created sketch information: + - name: Sketch name + - label: Sketch label + - type_id: Object type + - support: What the sketch is attached to + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + doc = FreeCAD.newDocument("Unnamed") + +sketch_name = {name!r} or "Sketch" + +if {body_name!r}: + body = doc.getObject({body_name!r}) + if body is None: + raise ValueError(f"Body not found: {body_name!r}") + + # Add sketch to body + sketch = body.newObject("Sketcher::SketchObject", sketch_name) + + # Set support plane + plane = {plane!r} + if plane in ["XY_Plane", "XZ_Plane", "YZ_Plane"]: + sketch.Support = (body.Origin.getObject(plane), [""]) + sketch.MapMode = "FlatFace" + elif plane.startswith("Face"): + # Attach to face + sketch.Support = (body, [plane]) + sketch.MapMode = "FlatFace" +else: + # Standalone sketch + sketch = doc.addObject("Sketcher::SketchObject", sketch_name) + + plane = {plane!r} + if plane == "XY_Plane": + sketch.Placement = FreeCAD.Placement(FreeCAD.Vector(0,0,0), FreeCAD.Rotation(0,0,0,1)) + elif plane == "XZ_Plane": + sketch.Placement = FreeCAD.Placement(FreeCAD.Vector(0,0,0), FreeCAD.Rotation(FreeCAD.Vector(1,0,0), 90)) + elif plane == "YZ_Plane": + sketch.Placement = FreeCAD.Placement(FreeCAD.Vector(0,0,0), FreeCAD.Rotation(FreeCAD.Vector(0,1,0), 90)) + +doc.recompute() + +_result_ = {{ + "name": sketch.Name, + "label": sketch.Label, + "type_id": sketch.TypeId, + "support": str(sketch.Support) if hasattr(sketch, "Support") else None, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Create sketch failed") + + @mcp.tool() + async def add_sketch_rectangle( + sketch_name: str, + x: float, + y: float, + width: float, + height: float, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add a rectangle to a sketch. + + Args: + sketch_name: Name of the sketch to add rectangle to. + x: X coordinate of bottom-left corner. + y: Y coordinate of bottom-left corner. + width: Rectangle width. + height: Rectangle height. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with geometry info: + - constraint_count: Number of constraints in sketch + - geometry_count: Number of geometry elements + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Add rectangle +import Part + +x, y, w, h = {x}, {y}, {width}, {height} + +# Add lines +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x, y, 0), FreeCAD.Vector(x+w, y, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x+w, y, 0), FreeCAD.Vector(x+w, y+h, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x+w, y+h, 0), FreeCAD.Vector(x, y+h, 0)), False) +sketch.addGeometry(Part.LineSegment(FreeCAD.Vector(x, y+h, 0), FreeCAD.Vector(x, y, 0)), False) + +# Add coincident constraints to close the rectangle +n = sketch.GeometryCount - 4 +sketch.addConstraint(Sketcher.Constraint("Coincident", n, 2, n+1, 1)) +sketch.addConstraint(Sketcher.Constraint("Coincident", n+1, 2, n+2, 1)) +sketch.addConstraint(Sketcher.Constraint("Coincident", n+2, 2, n+3, 1)) +sketch.addConstraint(Sketcher.Constraint("Coincident", n+3, 2, n, 1)) + +doc.recompute() + +_result_ = {{ + "constraint_count": sketch.ConstraintCount, + "geometry_count": sketch.GeometryCount, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Add rectangle failed") + + @mcp.tool() + async def add_sketch_circle( + sketch_name: str, + center_x: float, + center_y: float, + radius: float, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add a circle to a sketch. + + Args: + sketch_name: Name of the sketch to add circle to. + center_x: X coordinate of center. + center_y: Y coordinate of center. + radius: Circle radius. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with geometry info: + - geometry_index: Index of the added circle + - geometry_count: Total geometry elements + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +import Part + +idx = sketch.addGeometry(Part.Circle(FreeCAD.Vector({center_x}, {center_y}, 0), FreeCAD.Vector(0,0,1), {radius}), False) +doc.recompute() + +_result_ = {{ + "geometry_index": idx, + "geometry_count": sketch.GeometryCount, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Add circle failed") + + @mcp.tool() + async def pad_sketch( + sketch_name: str, + length: float, + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Pad (extrusion) from a sketch. + + Args: + sketch_name: Name of the sketch to pad. + length: Pad length (extrusion distance). + symmetric: Whether to extrude symmetrically. Defaults to False. + reversed: Whether to reverse direction. Defaults to False. + name: Pad feature name. Auto-generated if None. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with created pad information: + - name: Pad name + - label: Pad label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Find the body containing this sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketch in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketch must be inside a PartDesign Body for Pad operation") + +pad_name = {name!r} or "Pad" +pad = body.newObject("PartDesign::Pad", pad_name) +pad.Profile = sketch +pad.Length = {length} +pad.Symmetric = {symmetric} +pad.Reversed = {reversed} + +doc.recompute() + +_result_ = {{ + "name": pad.Name, + "label": pad.Label, + "type_id": pad.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Pad failed") + + @mcp.tool() + async def pocket_sketch( + sketch_name: str, + length: float, + type: str = "Length", + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Pocket (cut extrusion) from a sketch. + + Args: + sketch_name: Name of the sketch to pocket. + length: Pocket depth. + type: Pocket type: "Length", "ThroughAll", "UpToFirst", "UpToFace". + name: Pocket feature name. Auto-generated if None. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with created pocket information: + - name: Pocket name + - label: Pocket label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Find the body containing this sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketch in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketch must be inside a PartDesign Body for Pocket operation") + +pocket_name = {name!r} or "Pocket" +pocket = body.newObject("PartDesign::Pocket", pocket_name) +pocket.Profile = sketch +pocket.Length = {length} +pocket.Type = {type!r} + +doc.recompute() + +_result_ = {{ + "name": pocket.Name, + "label": pocket.Label, + "type_id": pocket.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Pocket failed") + + @mcp.tool() + async def fillet_edges( + object_name: str, + radius: float, + edges: list[str] | None = None, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add fillet (rounded edges) to an object. + + Args: + object_name: Name of the object to fillet. + radius: Fillet radius. + edges: List of edge names to fillet (e.g., ["Edge1", "Edge2"]). + Fillets all edges if None. + name: Fillet feature name. Auto-generated if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with created fillet information: + - name: Fillet name + - label: Fillet label + - type_id: Object type + """ + bridge = await get_bridge() + + edges_str = edges if edges else "None" + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +fillet_name = {name!r} or "Fillet" + +# Check if this is in a PartDesign Body +body = None +for parent in doc.Objects: + if parent.TypeId == "PartDesign::Body": + if hasattr(parent, "Group") and obj in parent.Group: + body = parent + break + +if body: + # PartDesign Fillet + fillet = body.newObject("PartDesign::Fillet", fillet_name) + fillet.Base = (obj, {edges_str!r} or obj.Shape.Edges) + fillet.Radius = {radius} +else: + # Part Fillet + fillet = doc.addObject("Part::Fillet", fillet_name) + fillet.Base = obj + + if {edges_str!r}: + edge_list = [(int(e.replace("Edge", "")), {radius}, {radius}) for e in {edges_str!r}] + else: + edge_list = [(i+1, {radius}, {radius}) for i in range(len(obj.Shape.Edges))] + + fillet.Edges = edge_list + +doc.recompute() + +_result_ = {{ + "name": fillet.Name, + "label": fillet.Label, + "type_id": fillet.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Fillet failed") + + @mcp.tool() + async def chamfer_edges( + object_name: str, + size: float, + edges: list[str] | None = None, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add chamfer (beveled edges) to an object. + + Args: + object_name: Name of the object to chamfer. + size: Chamfer size. + edges: List of edge names to chamfer (e.g., ["Edge1", "Edge2"]). + Chamfers all edges if None. + name: Chamfer feature name. Auto-generated if None. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with created chamfer information: + - name: Chamfer name + - label: Chamfer label + - type_id: Object type + """ + bridge = await get_bridge() + + edges_str = edges if edges else "None" + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +obj = doc.getObject({object_name!r}) +if obj is None: + raise ValueError(f"Object not found: {object_name!r}") + +chamfer_name = {name!r} or "Chamfer" + +# Check if this is in a PartDesign Body +body = None +for parent in doc.Objects: + if parent.TypeId == "PartDesign::Body": + if hasattr(parent, "Group") and obj in parent.Group: + body = parent + break + +if body: + # PartDesign Chamfer + chamfer = body.newObject("PartDesign::Chamfer", chamfer_name) + chamfer.Base = (obj, {edges_str!r} or obj.Shape.Edges) + chamfer.Size = {size} +else: + # Part Chamfer + chamfer = doc.addObject("Part::Chamfer", chamfer_name) + chamfer.Base = obj + + if {edges_str!r}: + edge_list = [(int(e.replace("Edge", "")), {size}, {size}) for e in {edges_str!r}] + else: + edge_list = [(i+1, {size}, {size}) for i in range(len(obj.Shape.Edges))] + + chamfer.Edges = edge_list + +doc.recompute() + +_result_ = {{ + "name": chamfer.Name, + "label": chamfer.Label, + "type_id": chamfer.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Chamfer failed") + + @mcp.tool() + async def revolution_sketch( + sketch_name: str, + angle: float = 360.0, + axis: str = "Base_X", + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Revolution (rotational extrusion) from a sketch. + + Revolves the sketch profile around an axis to create a solid of revolution. + + Args: + sketch_name: Name of the sketch to revolve. + angle: Revolution angle in degrees. Defaults to 360. + axis: Axis to revolve around. Options: + - "Base_X" - X axis + - "Base_Y" - Y axis + - "Base_Z" - Z axis + - "Sketch_V" - Sketch vertical axis + - "Sketch_H" - Sketch horizontal axis + symmetric: Whether to revolve symmetrically. Defaults to False. + reversed: Whether to reverse direction. Defaults to False. + name: Revolution feature name. Auto-generated if None. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with created revolution information: + - name: Revolution name + - label: Revolution label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Find the body containing this sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketch in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketch must be inside a PartDesign Body for Revolution operation") + +rev_name = {name!r} or "Revolution" +rev = body.newObject("PartDesign::Revolution", rev_name) +rev.Profile = sketch +rev.Angle = {angle} +rev.Symmetric = {symmetric} +rev.Reversed = {reversed} + +# Set axis reference +axis_name = {axis!r} +if axis_name.startswith("Base_"): + axis_ref = axis_name.replace("Base_", "") + rev.ReferenceAxis = (body.Origin.getObject(f"{{axis_ref}}_Axis"), [""]) +elif axis_name.startswith("Sketch_"): + if axis_name == "Sketch_V": + rev.ReferenceAxis = (sketch, ["V_Axis"]) + else: + rev.ReferenceAxis = (sketch, ["H_Axis"]) + +doc.recompute() + +_result_ = {{ + "name": rev.Name, + "label": rev.Label, + "type_id": rev.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Revolution failed") + + @mcp.tool() + async def groove_sketch( + sketch_name: str, + angle: float = 360.0, + axis: str = "Base_X", + symmetric: bool = False, + reversed: bool = False, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Groove (subtractive revolution) from a sketch. + + Revolves a sketch profile and subtracts it from existing material. + + Args: + sketch_name: Name of the sketch to revolve. + angle: Groove angle in degrees. Defaults to 360. + axis: Axis to revolve around. Options: + - "Base_X" - X axis + - "Base_Y" - Y axis + - "Base_Z" - Z axis + - "Sketch_V" - Sketch vertical axis + - "Sketch_H" - Sketch horizontal axis + symmetric: Whether to revolve symmetrically. Defaults to False. + reversed: Whether to reverse direction. Defaults to False. + name: Groove feature name. Auto-generated if None. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with created groove information: + - name: Groove name + - label: Groove label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Find the body containing this sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketch in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketch must be inside a PartDesign Body for Groove operation") + +groove_name = {name!r} or "Groove" +groove = body.newObject("PartDesign::Groove", groove_name) +groove.Profile = sketch +groove.Angle = {angle} +groove.Symmetric = {symmetric} +groove.Reversed = {reversed} + +# Set axis reference +axis_name = {axis!r} +if axis_name.startswith("Base_"): + axis_ref = axis_name.replace("Base_", "") + groove.ReferenceAxis = (body.Origin.getObject(f"{{axis_ref}}_Axis"), [""]) +elif axis_name.startswith("Sketch_"): + if axis_name == "Sketch_V": + groove.ReferenceAxis = (sketch, ["V_Axis"]) + else: + groove.ReferenceAxis = (sketch, ["H_Axis"]) + +doc.recompute() + +_result_ = {{ + "name": groove.Name, + "label": groove.Label, + "type_id": groove.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Groove failed") + + @mcp.tool() + async def create_hole( + sketch_name: str, + diameter: float = 6.0, + depth: float = 10.0, + hole_type: str = "Dimension", + threaded: bool = False, + thread_type: str = "ISO", + thread_size: str = "M6", + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Hole feature from a sketch containing point(s). + + Creates parametric holes with optional threading. The sketch should + contain points defining hole center locations. + + Args: + sketch_name: Name of the sketch with hole center point(s). + diameter: Hole diameter (for non-threaded). Defaults to 6.0. + depth: Hole depth. Defaults to 10.0. + hole_type: Hole depth type. Options: + - "Dimension" - Specific depth + - "ThroughAll" - Through entire part + - "UpToFirst" - Up to first face + threaded: Whether hole is threaded. Defaults to False. + thread_type: Thread standard. Options: "ISO", "UNC", "UNF". + thread_size: Thread size (e.g., "M6", "M8", "#10", "1/4"). + name: Hole feature name. Auto-generated if None. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with created hole information: + - name: Hole name + - label: Hole label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +# Find the body containing this sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketch in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketch must be inside a PartDesign Body for Hole operation") + +hole_name = {name!r} or "Hole" +hole = body.newObject("PartDesign::Hole", hole_name) +hole.Profile = sketch +hole.Depth = {depth} + +# Set hole type +hole_type = {hole_type!r} +if hole_type == "ThroughAll": + hole.DepthType = 1 +elif hole_type == "UpToFirst": + hole.DepthType = 2 +else: + hole.DepthType = 0 # Dimension + +# Set threading +if {threaded}: + hole.Threaded = True + hole.ThreadType = {thread_type!r} + hole.ThreadSize = {thread_size!r} +else: + hole.Threaded = False + hole.Diameter = {diameter} + +doc.recompute() + +_result_ = {{ + "name": hole.Name, + "label": hole.Label, + "type_id": hole.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Hole creation failed") + + @mcp.tool() + async def linear_pattern( + feature_name: str, + direction: str = "X", + length: float = 50.0, + occurrences: int = 3, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Linear Pattern from a PartDesign feature. + + Repeats a feature in a linear direction. + + Args: + feature_name: Name of the feature to pattern. + direction: Pattern direction. Options: "X", "Y", "Z". + length: Total pattern length. Defaults to 50.0. + occurrences: Number of pattern instances. Defaults to 3. + name: Pattern feature name. Auto-generated if None. + doc_name: Document containing the feature. Uses active document if None. + + Returns: + Dictionary with created pattern information: + - name: Pattern name + - label: Pattern label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +feature = doc.getObject({feature_name!r}) +if feature is None: + raise ValueError(f"Feature not found: {feature_name!r}") + +# Find the body containing this feature +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and feature in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Feature must be inside a PartDesign Body") + +pattern_name = {name!r} or "LinearPattern" +pattern = body.newObject("PartDesign::LinearPattern", pattern_name) +pattern.Originals = [feature] +pattern.Length = {length} +pattern.Occurrences = {occurrences} + +# Set direction +dir_name = {direction!r} +pattern.Direction = (body.Origin.getObject(f"{{dir_name}}_Axis"), [""]) + +doc.recompute() + +_result_ = {{ + "name": pattern.Name, + "label": pattern.Label, + "type_id": pattern.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Linear pattern failed") + + @mcp.tool() + async def polar_pattern( + feature_name: str, + axis: str = "Z", + angle: float = 360.0, + occurrences: int = 6, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Polar (circular) Pattern from a PartDesign feature. + + Repeats a feature around an axis. + + Args: + feature_name: Name of the feature to pattern. + axis: Pattern axis. Options: "X", "Y", "Z". + angle: Total pattern angle. Defaults to 360.0. + occurrences: Number of pattern instances. Defaults to 6. + name: Pattern feature name. Auto-generated if None. + doc_name: Document containing the feature. Uses active document if None. + + Returns: + Dictionary with created pattern information: + - name: Pattern name + - label: Pattern label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +feature = doc.getObject({feature_name!r}) +if feature is None: + raise ValueError(f"Feature not found: {feature_name!r}") + +# Find the body containing this feature +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and feature in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Feature must be inside a PartDesign Body") + +pattern_name = {name!r} or "PolarPattern" +pattern = body.newObject("PartDesign::PolarPattern", pattern_name) +pattern.Originals = [feature] +pattern.Angle = {angle} +pattern.Occurrences = {occurrences} + +# Set axis +axis_name = {axis!r} +pattern.Axis = (body.Origin.getObject(f"{{axis_name}}_Axis"), [""]) + +doc.recompute() + +_result_ = {{ + "name": pattern.Name, + "label": pattern.Label, + "type_id": pattern.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Polar pattern failed") + + @mcp.tool() + async def mirrored_feature( + feature_name: str, + plane: str = "XY", + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Mirrored feature from a PartDesign feature. + + Mirrors a feature across a plane. + + Args: + feature_name: Name of the feature to mirror. + plane: Mirror plane. Options: "XY", "XZ", "YZ". + name: Mirrored feature name. Auto-generated if None. + doc_name: Document containing the feature. Uses active document if None. + + Returns: + Dictionary with created mirror information: + - name: Mirror name + - label: Mirror label + - type_id: Object type + """ + bridge = await get_bridge() + + plane_map = { + "XY": "XY_Plane", + "XZ": "XZ_Plane", + "YZ": "YZ_Plane", + } + + if plane not in plane_map: + raise ValueError(f"Invalid plane: {plane}. Use: XY, XZ, YZ") + + plane_ref = plane_map[plane] + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +feature = doc.getObject({feature_name!r}) +if feature is None: + raise ValueError(f"Feature not found: {feature_name!r}") + +# Find the body containing this feature +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and feature in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Feature must be inside a PartDesign Body") + +mirror_name = {name!r} or "Mirrored" +mirror = body.newObject("PartDesign::Mirrored", mirror_name) +mirror.Originals = [feature] +mirror.MirrorPlane = (body.Origin.getObject({plane_ref!r}), [""]) + +doc.recompute() + +_result_ = {{ + "name": mirror.Name, + "label": mirror.Label, + "type_id": mirror.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Mirrored feature failed") + + @mcp.tool() + async def add_sketch_line( + sketch_name: str, + x1: float, + y1: float, + x2: float, + y2: float, + construction: bool = False, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add a line to a sketch. + + Args: + sketch_name: Name of the sketch to add line to. + x1: X coordinate of start point. + y1: Y coordinate of start point. + x2: X coordinate of end point. + y2: Y coordinate of end point. + construction: Whether this is a construction line. Defaults to False. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with geometry info: + - geometry_index: Index of the added line + - geometry_count: Total geometry elements + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +import Part + +idx = sketch.addGeometry( + Part.LineSegment( + FreeCAD.Vector({x1}, {y1}, 0), + FreeCAD.Vector({x2}, {y2}, 0) + ), + {construction} +) +doc.recompute() + +_result_ = {{ + "geometry_index": idx, + "geometry_count": sketch.GeometryCount, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Add line failed") + + @mcp.tool() + async def add_sketch_arc( + sketch_name: str, + center_x: float, + center_y: float, + radius: float, + start_angle: float, + end_angle: float, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add an arc to a sketch. + + Args: + sketch_name: Name of the sketch to add arc to. + center_x: X coordinate of center. + center_y: Y coordinate of center. + radius: Arc radius. + start_angle: Start angle in degrees. + end_angle: End angle in degrees. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with geometry info: + - geometry_index: Index of the added arc + - geometry_count: Total geometry elements + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +import Part +import math + +center = FreeCAD.Vector({center_x}, {center_y}, 0) +start_rad = math.radians({start_angle}) +end_rad = math.radians({end_angle}) + +arc = Part.ArcOfCircle( + Part.Circle(center, FreeCAD.Vector(0, 0, 1), {radius}), + start_rad, + end_rad +) +idx = sketch.addGeometry(arc, False) +doc.recompute() + +_result_ = {{ + "geometry_index": idx, + "geometry_count": sketch.GeometryCount, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Add arc failed") + + @mcp.tool() + async def add_sketch_point( + sketch_name: str, + x: float, + y: float, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Add a point to a sketch. + + Points are useful for defining hole centers and reference locations. + + Args: + sketch_name: Name of the sketch to add point to. + x: X coordinate. + y: Y coordinate. + doc_name: Document containing the sketch. Uses active document if None. + + Returns: + Dictionary with geometry info: + - geometry_index: Index of the added point + - geometry_count: Total geometry elements + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +sketch = doc.getObject({sketch_name!r}) +if sketch is None: + raise ValueError(f"Sketch not found: {sketch_name!r}") + +import Part + +idx = sketch.addGeometry(Part.Point(FreeCAD.Vector({x}, {y}, 0)), False) +doc.recompute() + +_result_ = {{ + "geometry_index": idx, + "geometry_count": sketch.GeometryCount, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Add point failed") + + @mcp.tool() + async def loft_sketches( + sketch_names: list[str], + ruled: bool = False, + closed: bool = False, + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Loft (additive) through multiple sketches. + + A loft creates a solid by connecting multiple profile sketches. + + Args: + sketch_names: List of sketch names to loft through (in order). + ruled: Whether to create ruled surfaces. Defaults to False. + closed: Whether to close the loft. Defaults to False. + name: Loft feature name. Auto-generated if None. + doc_name: Document containing the sketches. Uses active document if None. + + Returns: + Dictionary with created loft information: + - name: Loft name + - label: Loft label + - type_id: Object type + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + +sketches = [] +for sname in {sketch_names!r}: + sketch = doc.getObject(sname) + if sketch is None: + raise ValueError(f"Sketch not found: {{sname}}") + sketches.append(sketch) + +if len(sketches) < 2: + raise ValueError("Loft requires at least 2 sketches") + +# Find the body containing the first sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and sketches[0] in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketches must be inside a PartDesign Body for Loft operation") + +loft_name = {name!r} or "Loft" +loft = body.newObject("PartDesign::AdditiveLoft", loft_name) +loft.Profile = sketches[0] +loft.Sections = sketches[1:] +loft.Ruled = {ruled} +loft.Closed = {closed} + +doc.recompute() + +_result_ = {{ + "name": loft.Name, + "label": loft.Label, + "type_id": loft.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Loft failed") + + @mcp.tool() + async def sweep_sketch( + profile_sketch: str, + spine_sketch: str, + transition: str = "Transformed", + name: str | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Create a Sweep (additive) along a spine path. + + A sweep extrudes a profile sketch along a path defined by another sketch. + + Args: + profile_sketch: Name of the profile sketch to sweep. + spine_sketch: Name of the spine (path) sketch. + transition: Transition mode. Options: + - "Transformed" - Smooth transitions + - "Right" - Sharp corners + - "Round" - Rounded corners + name: Sweep feature name. Auto-generated if None. + doc_name: Document containing the sketches. Uses active document if None. + + Returns: + Dictionary with created sweep information: + - name: Sweep name + - label: Sweep label + - type_id: Object type + """ + bridge = await get_bridge() + + transition_map = { + "Transformed": 0, + "Right": 1, + "Round": 2, + } + + if transition not in transition_map: + raise ValueError( + f"Invalid transition: {transition}. Use: Transformed, Right, Round" + ) + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + +profile = doc.getObject({profile_sketch!r}) +if profile is None: + raise ValueError(f"Profile sketch not found: {profile_sketch!r}") + +spine = doc.getObject({spine_sketch!r}) +if spine is None: + raise ValueError(f"Spine sketch not found: {spine_sketch!r}") + +# Find the body containing the profile sketch +body = None +for obj in doc.Objects: + if obj.TypeId == "PartDesign::Body": + if hasattr(obj, "Group") and profile in obj.Group: + body = obj + break + +if body is None: + raise ValueError("Sketches must be inside a PartDesign Body for Sweep operation") + +sweep_name = {name!r} or "Sweep" +sweep = body.newObject("PartDesign::AdditivePipe", sweep_name) +sweep.Profile = profile +sweep.Spine = (spine, ["Edge1"]) +sweep.Transition = {transition_map[transition]} + +doc.recompute() + +_result_ = {{ + "name": sweep.Name, + "label": sweep.Label, + "type_id": sweep.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + raise ValueError(result.error_traceback or "Sweep failed") diff --git a/src/freecad_mcp/tools/view.py b/src/freecad_mcp/tools/view.py new file mode 100644 index 0000000..3b36c27 --- /dev/null +++ b/src/freecad_mcp/tools/view.py @@ -0,0 +1,782 @@ +"""View and screenshot tools for FreeCAD MCP server. + +This module provides tools for controlling the 3D view and +capturing screenshots. Based on learnings from neka-nat which +has excellent screenshot handling with view type detection. +""" + +from typing import Any + + +def register_view_tools(mcp, get_bridge) -> None: + """Register view-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance. + get_bridge: Async function to get the active bridge. + """ + + @mcp.tool() + async def get_screenshot( + view_angle: str = "Isometric", + width: int = 800, + height: int = 600, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Capture a screenshot of the FreeCAD 3D view. + + Requires GUI mode - will return an error in headless mode. + + Args: + view_angle: View angle to set before capture. Options: + - "Isometric" - 3D isometric view (default) + - "Front" - Front view (XZ plane) + - "Back" - Back view + - "Top" - Top view (XY plane) + - "Bottom" - Bottom view + - "Left" - Left view (YZ plane) + - "Right" - Right view + - "FitAll" - Fit all objects in view + width: Image width in pixels. Defaults to 800. + height: Image height in pixels. Defaults to 600. + doc_name: Document to capture. Uses active document if None. + + Returns: + Dictionary with screenshot result: + - success: Whether capture was successful + - data: Base64-encoded PNG image data (if success) + - format: Image format ("png") + - width: Actual image width + - height: Actual image height + - error: Error message (if not success) + """ + from freecad_mcp.bridge.base import ViewAngle + + # Map string to ViewAngle enum + angle_map = { + "Isometric": ViewAngle.ISOMETRIC, + "Front": ViewAngle.FRONT, + "Back": ViewAngle.BACK, + "Top": ViewAngle.TOP, + "Bottom": ViewAngle.BOTTOM, + "Left": ViewAngle.LEFT, + "Right": ViewAngle.RIGHT, + "FitAll": ViewAngle.FIT_ALL, + } + + if view_angle not in angle_map: + return { + "success": False, + "error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}", + } + + bridge = await get_bridge() + result = await bridge.get_screenshot( + view_angle=angle_map[view_angle], + width=width, + height=height, + doc_name=doc_name, + ) + + return { + "success": result.success, + "data": result.data, + "format": result.format, + "width": result.width, + "height": result.height, + "error": result.error, + } + + @mcp.tool() + async def set_view_angle( + view_angle: str, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the 3D view angle. + + Args: + view_angle: View angle to set. Options: + - "Isometric" - 3D isometric view + - "Front" - Front view (XZ plane) + - "Back" - Back view + - "Top" - Top view (XY plane) + - "Bottom" - Bottom view + - "Left" - Left view (YZ plane) + - "Right" - Right view + - "FitAll" - Fit all objects in view + doc_name: Document to set view for. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + from freecad_mcp.bridge.base import ViewAngle + + angle_map = { + "Isometric": ViewAngle.ISOMETRIC, + "Front": ViewAngle.FRONT, + "Back": ViewAngle.BACK, + "Top": ViewAngle.TOP, + "Bottom": ViewAngle.BOTTOM, + "Left": ViewAngle.LEFT, + "Right": ViewAngle.RIGHT, + "FitAll": ViewAngle.FIT_ALL, + } + + if view_angle not in angle_map: + return { + "success": False, + "error": f"Invalid view_angle: {view_angle}. Options: {list(angle_map.keys())}", + } + + bridge = await get_bridge() + await bridge.set_view(angle_map[view_angle], doc_name) + return {"success": True} + + @mcp.tool() + async def list_workbenches() -> list[dict[str, Any]]: + """List all available FreeCAD workbenches. + + Returns: + List of dictionaries, each containing: + - name: Workbench internal name + - label: Display label + - is_active: Whether workbench is currently active + """ + bridge = await get_bridge() + workbenches = await bridge.get_workbenches() + return [ + { + "name": wb.name, + "label": wb.label, + "is_active": wb.is_active, + } + for wb in workbenches + ] + + @mcp.tool() + async def activate_workbench(workbench_name: str) -> dict[str, Any]: + """Activate a FreeCAD workbench. + + Args: + workbench_name: Internal name of the workbench to activate. + Common workbenches: + - "PartWorkbench" - Part modeling + - "PartDesignWorkbench" - Parametric part design + - "SketcherWorkbench" - 2D sketching + - "DraftWorkbench" - 2D drafting + - "MeshWorkbench" - Mesh operations + - "SpreadsheetWorkbench" - Spreadsheet + + Returns: + Dictionary with result: + - success: Whether activation was successful + """ + bridge = await get_bridge() + await bridge.activate_workbench(workbench_name) + return {"success": True} + + @mcp.tool() + async def fit_all(doc_name: str | None = None) -> dict[str, Any]: + """Fit all objects in the current view. + + Adjusts the camera to show all visible objects in the document. + + Args: + doc_name: Document to fit view for. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + from freecad_mcp.bridge.base import ViewAngle + + bridge = await get_bridge() + await bridge.set_view(ViewAngle.FIT_ALL, doc_name) + return {"success": True} + + @mcp.tool() + async def set_object_visibility( + object_name: str, + visible: bool, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the visibility of a FreeCAD object. + + Args: + object_name: Name of the object. + visible: Whether object should be visible. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + - visible: New visibility state + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - visibility cannot be set in headless mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + else: + obj = doc.getObject({object_name!r}) + if obj is None: + _result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}} + elif hasattr(obj, "ViewObject") and obj.ViewObject: + obj.ViewObject.Visibility = {visible} + _result_ = {{"success": True, "visible": {visible}}} + else: + _result_ = {{"success": False, "error": "Object has no ViewObject"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Set visibility failed", + } + + @mcp.tool() + async def set_display_mode( + object_name: str, + mode: str, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the display mode of a FreeCAD object. + + Args: + object_name: Name of the object. + mode: Display mode. Common options: + - "Flat Lines" - Solid with edges + - "Shaded" - Solid without edges + - "Wireframe" - Wire frame only + - "Points" - Points only + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + - mode: New display mode + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - display mode cannot be set in headless mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + else: + obj = doc.getObject({object_name!r}) + if obj is None: + _result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}} + elif hasattr(obj, "ViewObject") and obj.ViewObject: + obj.ViewObject.DisplayMode = {mode!r} + _result_ = {{"success": True, "mode": {mode!r}}} + else: + _result_ = {{"success": False, "error": "Object has no ViewObject"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Set display mode failed", + } + + @mcp.tool() + async def set_object_color( + object_name: str, + color: list[float], + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the color of a FreeCAD object. + + Args: + object_name: Name of the object. + color: RGB color as [r, g, b] where each value is 0.0-1.0. + Example: [1.0, 0.0, 0.0] for red. + doc_name: Document containing the object. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + - color: New color values + """ + bridge = await get_bridge() + + if len(color) != 3: + return { + "success": False, + "error": "Color must be [r, g, b] with values 0.0-1.0", + } + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - color cannot be set in headless mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + else: + obj = doc.getObject({object_name!r}) + if obj is None: + _result_ = {{"success": False, "error": f"Object not found: {object_name!r}"}} + elif hasattr(obj, "ViewObject") and obj.ViewObject: + obj.ViewObject.ShapeColor = ({color[0]}, {color[1]}, {color[2]}) + _result_ = {{"success": True, "color": {color}}} + else: + _result_ = {{"success": False, "error": "Object has no ViewObject"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Set color failed", + } + + @mcp.tool() + async def zoom_in( + factor: float = 1.5, doc_name: str | None = None + ) -> dict[str, Any]: + """Zoom in the 3D view. + + Requires GUI mode. + + Args: + factor: Zoom factor (>1 zooms in). Defaults to 1.5. + doc_name: Document to zoom in. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None: + _result_ = {{"success": False, "error": "No active view"}} + else: + view = FreeCADGui.ActiveDocument.ActiveView + cam = view.getCameraNode() + if hasattr(cam, "scaleHeight"): + # For orthographic views + cam.scaleHeight(1.0 / {factor}) + else: + # For perspective views - move camera closer + view.zoomIn() + _result_ = {{"success": True}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Zoom in failed", + } + + @mcp.tool() + async def zoom_out( + factor: float = 1.5, doc_name: str | None = None + ) -> dict[str, Any]: + """Zoom out the 3D view. + + Requires GUI mode. + + Args: + factor: Zoom factor (>1 zooms out). Defaults to 1.5. + doc_name: Document to zoom out. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + bridge = await get_bridge() + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - zoom requires GUI mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None: + _result_ = {{"success": False, "error": "No active view"}} + else: + view = FreeCADGui.ActiveDocument.ActiveView + cam = view.getCameraNode() + if hasattr(cam, "scaleHeight"): + # For orthographic views + cam.scaleHeight({factor}) + else: + # For perspective views - move camera farther + view.zoomOut() + _result_ = {{"success": True}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Zoom out failed", + } + + @mcp.tool() + async def set_camera_position( + position: list[float], + look_at: list[float] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Set the camera position and orientation. + + Requires GUI mode. + + Args: + position: Camera position as [x, y, z]. + look_at: Point to look at as [x, y, z]. Uses origin if None. + doc_name: Document to set camera for. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether operation was successful + """ + bridge = await get_bridge() + + look_str = ( + f"FreeCAD.Vector({look_at[0]}, {look_at[1]}, {look_at[2]})" + if look_at + else "FreeCAD.Vector(0, 0, 0)" + ) + + code = f""" +if not FreeCAD.GuiUp: + _result_ = {{"success": False, "error": "GUI not available - camera position requires GUI mode"}} +else: + doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) + if doc is None: + _result_ = {{"success": False, "error": "No document found"}} + elif FreeCADGui.ActiveDocument is None or FreeCADGui.ActiveDocument.ActiveView is None: + _result_ = {{"success": False, "error": "No active view"}} + else: + view = FreeCADGui.ActiveDocument.ActiveView + pos = FreeCAD.Vector({position[0]}, {position[1]}, {position[2]}) + look_at = {look_str} + + # Calculate direction + direction = look_at - pos + direction.normalize() + + # Set camera + view.setCameraOrientation(FreeCAD.Rotation(FreeCAD.Vector(0, 0, -1), direction)) + cam = view.getCameraNode() + cam.position.setValue(pos.x, pos.y, pos.z) + + _result_ = {{"success": True}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Set camera position failed", + } + + @mcp.tool() + async def undo(doc_name: str | None = None) -> dict[str, Any]: + """Undo the last operation. + + Args: + doc_name: Document to undo in. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether undo was performed + - can_undo: Whether more undos are available + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + _result_ = {{"success": False, "can_undo": False, "error": "No document found"}} +elif doc.UndoCount > 0: + doc.undo() + _result_ = {{"success": True, "can_undo": doc.UndoCount > 0}} +else: + _result_ = {{"success": False, "can_undo": False, "error": "Nothing to undo"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "can_undo": False, + "error": result.error_traceback or "Undo failed", + } + + @mcp.tool() + async def redo(doc_name: str | None = None) -> dict[str, Any]: + """Redo the last undone operation. + + Args: + doc_name: Document to redo in. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether redo was performed + - can_redo: Whether more redos are available + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + _result_ = {{"success": False, "can_redo": False, "error": "No document found"}} +elif doc.RedoCount > 0: + doc.redo() + _result_ = {{"success": True, "can_redo": doc.RedoCount > 0}} +else: + _result_ = {{"success": False, "can_redo": False, "error": "Nothing to redo"}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "can_redo": False, + "error": result.error_traceback or "Redo failed", + } + + @mcp.tool() + async def get_undo_redo_status(doc_name: str | None = None) -> dict[str, Any]: + """Get the current undo/redo status. + + Args: + doc_name: Document to check. Uses active document if None. + + Returns: + Dictionary with status: + - undo_count: Number of undoable operations + - redo_count: Number of redoable operations + - undo_names: List of undo operation names (if available) + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + _result_ = {{"error": "No document found", "undo_count": 0, "redo_count": 0, "undo_names": []}} +else: + _result_ = {{ + "undo_count": doc.UndoCount, + "redo_count": doc.RedoCount, + "undo_names": list(doc.UndoNames) if hasattr(doc, "UndoNames") else [], + }} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "error": result.error_traceback or "Get undo/redo status failed", + "undo_count": 0, + "redo_count": 0, + "undo_names": [], + } + + @mcp.tool() + async def list_parts_library() -> list[dict[str, Any]]: + """List available parts from the FreeCAD parts library. + + Returns: + List of parts with: + - name: Part filename + - path: Full path to part file + - category: Part category/folder + """ + bridge = await get_bridge() + + code = """ +import os + +parts = [] + +# Get parts library paths +try: + # Standard library path + lib_path = FreeCAD.getResourceDir() + "Mod/Parts_Library" + if not os.path.exists(lib_path): + lib_path = os.path.expanduser("~/.FreeCAD/Mod/PartsLibrary") + + if os.path.exists(lib_path): + for root, dirs, files in os.walk(lib_path): + category = os.path.relpath(root, lib_path) + if category == ".": + category = "Root" + + for f in files: + if f.endswith((".FCStd", ".step", ".stp", ".iges", ".igs")): + parts.append({ + "name": f, + "path": os.path.join(root, f), + "category": category, + }) +except Exception as e: + pass + +_result_ = parts +""" + result = await bridge.execute_python(code) + if result.success: + return result.result + return [] + + @mcp.tool() + async def insert_part_from_library( + part_path: str, + name: str | None = None, + position: list[float] | None = None, + doc_name: str | None = None, + ) -> dict[str, Any]: + """Insert a part from the parts library into the document. + + Args: + part_path: Path to the part file. + name: Name for the inserted part. Auto-generated if None. + position: Initial position [x, y, z]. Origin if None. + doc_name: Target document. Uses active document if None. + + Returns: + Dictionary with inserted part information: + - name: Part name + - label: Part label + - type_id: Part type + """ + bridge = await get_bridge() + + pos_str = ( + f"FreeCAD.Vector({position[0]}, {position[1]}, {position[2]})" + if position + else "FreeCAD.Vector(0, 0, 0)" + ) + + code = f""" +import os +import Part + +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + doc = FreeCAD.newDocument("Unnamed") + +part_path = {part_path!r} +if not os.path.exists(part_path): + raise FileNotFoundError(f"Part file not found: {{part_path}}") + +ext = os.path.splitext(part_path)[1].lower() +part_name = {name!r} or os.path.splitext(os.path.basename(part_path))[0] + +if ext == ".fcstd": + # Import FreeCAD document + src_doc = FreeCAD.openDocument(part_path) + for obj in src_doc.Objects: + if hasattr(obj, "Shape"): + new_obj = doc.addObject("Part::Feature", part_name) + new_obj.Shape = obj.Shape.copy() + break + FreeCAD.closeDocument(src_doc.Name) +else: + # Import STEP/IGES + shape = Part.read(part_path) + new_obj = doc.addObject("Part::Feature", part_name) + new_obj.Shape = shape + +# Set position +new_obj.Placement.Base = {pos_str} + +doc.recompute() + +_result_ = {{ + "name": new_obj.Name, + "label": new_obj.Label, + "type_id": new_obj.TypeId, +}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Insert part from library failed", + } + + @mcp.tool() + async def get_console_log(lines: int = 50) -> dict[str, Any]: + """Get recent console output from FreeCAD. + + Args: + lines: Maximum number of lines to return. Defaults to 50. + + Returns: + Dictionary with: + - messages: List of console messages + - warnings: List of warning messages + - errors: List of error messages + """ + bridge = await get_bridge() + console_lines = await bridge.get_console_output(lines) + + return { + "messages": console_lines, + "warnings": [line for line in console_lines if "warning" in line.lower()], + "errors": [line for line in console_lines if "error" in line.lower()], + } + + @mcp.tool() + async def recompute(doc_name: str | None = None) -> dict[str, Any]: + """Force recompute of all objects in a document. + + Args: + doc_name: Document to recompute. Uses active document if None. + + Returns: + Dictionary with result: + - success: Whether recompute completed + - touch_count: Number of objects that were recomputed + """ + bridge = await get_bridge() + + code = f""" +doc = FreeCAD.ActiveDocument if {doc_name!r} is None else FreeCAD.getDocument({doc_name!r}) +if doc is None: + _result_ = {{"success": False, "error": "No document found", "touch_count": 0}} +else: + # Touch all objects to force recompute + touch_count = 0 + for obj in doc.Objects: + if hasattr(obj, "touch"): + obj.touch() + touch_count += 1 + + doc.recompute() + + _result_ = {{"success": True, "touch_count": touch_count}} +""" + result = await bridge.execute_python(code) + if result.success and result.result: + return result.result + return { + "success": False, + "error": result.error_traceback or "Recompute failed", + "touch_count": 0, + } diff --git a/src/freecad_mcp/utils/__init__.py b/src/freecad_mcp/utils/__init__.py new file mode 100644 index 0000000..7d95357 --- /dev/null +++ b/src/freecad_mcp/utils/__init__.py @@ -0,0 +1,4 @@ +"""Utility modules for FreeCAD MCP server. + +This package contains shared utilities for serialization, validation, etc. +""" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c5ebe4f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for FreeCAD MCP Server.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4ce2766 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +"""Pytest configuration and shared fixtures.""" + +import pytest + + +@pytest.fixture +def sample_python_code(): + """Sample Python code for execution tests.""" + return """ +x = 1 + 1 +_result_ = {"value": x, "type": type(x).__name__} +""" + + +@pytest.fixture +def sample_freecad_code(): + """Sample FreeCAD Python code.""" + return """ +import Part +box = Part.makeBox(10, 20, 30) +_result_ = { + "volume": box.Volume, + "area": box.Area, + "is_valid": box.isValid(), +} +""" + + +@pytest.fixture +def mock_execution_result(): + """Mock execution result for testing.""" + from freecad_mcp.bridge.base import ExecutionResult + + return ExecutionResult( + success=True, + result={"value": 42}, + stdout="", + stderr="", + execution_time_ms=10.5, + ) + + +@pytest.fixture +def mock_document_info(): + """Mock document info for testing.""" + from freecad_mcp.bridge.base import DocumentInfo + + return DocumentInfo( + name="TestDoc", + path="/tmp/test.FCStd", + objects=["Box", "Cylinder"], + is_modified=False, + label="Test Document", + ) + + +@pytest.fixture +def mock_object_info(): + """Mock object info for testing.""" + from freecad_mcp.bridge.base import ObjectInfo + + return ObjectInfo( + name="Box", + label="My Box", + type_id="Part::Box", + properties={"Length": 10.0, "Width": 20.0, "Height": 30.0}, + shape_info={ + "type": "Solid", + "volume": 6000.0, + "area": 2200.0, + "is_valid": True, + }, + children=[], + ) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..10c94b6 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests requiring FreeCAD installation.""" diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..d8f0d0a --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,100 @@ +"""Pytest configuration for integration tests. + +This module handles connection checking and provides consolidated skip behavior +when the FreeCAD MCP bridge is not available. +""" + +from __future__ import annotations + +import warnings +import xmlrpc.client +from typing import Any + +import pytest + +# Global flag to track bridge availability (checked once per session) +_bridge_available: bool | None = None +_bridge_error: str | None = None +_warning_emitted: bool = False + + +def _check_bridge_connection() -> tuple[bool, str | None]: + """Check if the FreeCAD MCP bridge is available. + + Returns: + Tuple of (is_available, error_message) + """ + global _bridge_available, _bridge_error + + if _bridge_available is not None: + return _bridge_available, _bridge_error + + try: + proxy = xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True) + result: dict[str, Any] = proxy.ping() # type: ignore[assignment] + if result.get("pong"): + _bridge_available = True + _bridge_error = None + else: + _bridge_available = False + _bridge_error = "FreeCAD MCP bridge not responding to ping" + except ConnectionRefusedError: + _bridge_available = False + _bridge_error = "Connection refused - FreeCAD MCP bridge not running" + except Exception as e: + _bridge_available = False + _bridge_error = f"Cannot connect to FreeCAD MCP bridge: {e}" + + return _bridge_available, _bridge_error + + +def pytest_collection_modifyitems( + _config: pytest.Config, items: list[pytest.Item] +) -> None: + """Skip all integration tests if the bridge is not available. + + This runs once during test collection and emits a single warning instead of + per-test skip messages. + """ + global _warning_emitted + + # Filter to only integration tests in this directory + integration_tests = [ + item for item in items if "tests/integration" in str(item.fspath) + ] + + if not integration_tests: + return + + # Check bridge connection once + is_available, error = _check_bridge_connection() + + if not is_available: + # Apply skip marker to all integration tests + skip_marker = pytest.mark.skip(reason="FreeCAD MCP bridge unavailable") + for item in integration_tests: + item.add_marker(skip_marker) + + # Emit a single warning (only once) + if not _warning_emitted: + _warning_emitted = True + warnings.warn( + f"Skipping {len(integration_tests)} integration tests: {error}. " + f"Start the bridge with 'just run-gui' or 'just run-headless'.", + pytest.PytestWarning, + stacklevel=1, + ) + + +@pytest.fixture(scope="module") +def xmlrpc_proxy() -> xmlrpc.client.ServerProxy: + """Create XML-RPC proxy to FreeCAD MCP bridge. + + This fixture is shared across all integration test modules. + The connection check has already been performed during collection. + """ + is_available, error = _check_bridge_connection() + if not is_available: + pytest.skip(error or "FreeCAD MCP bridge not available") + + return xmlrpc.client.ServerProxy("http://localhost:9875", allow_none=True) diff --git a/tests/integration/test_cut_object_for_magnets.py b/tests/integration/test_cut_object_for_magnets.py new file mode 100644 index 0000000..17c46c5 --- /dev/null +++ b/tests/integration/test_cut_object_for_magnets.py @@ -0,0 +1,1028 @@ +"""Integration tests for the CutObjectForMagnets macro. + +These tests verify the SmartCutter class functionality including: +- Cutting solid objects with PartDesign::Hole features +- Cutting hollow objects with PartDesign::Hole features +- Fallback boolean hole creation method + +Note: These tests require a running FreeCAD MCP bridge. + Start it with: just run-gui or just run-headless + +To run these tests: + pytest tests/integration/test_cut_object_for_magnets.py -v +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + import xmlrpc.client + +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration + +# Note: xmlrpc_proxy fixture is defined in conftest.py + + +def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]: + """Execute Python code via the MCP bridge and return the result.""" + result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment] + assert result.get("success"), f"Execution failed: {result.get('error_traceback')}" + return result + + +# The SmartCutter class code embedded for testing +# This avoids issues with importing the macro file directly +SMART_CUTTER_CODE = ''' +import FreeCAD as App +import Part + + +class HolePlacementError(Exception): + """Raised when hole placement fails.""" + pass + + +class SmartCutter: + """Handles cutting objects and placing magnet holes with collision detection.""" + + def __init__(self, obj, params: dict): + """Initialize the cutter.""" + self.obj = obj + self.params = params + self.shape = obj.Shape + + def get_cut_plane_normal_and_point(self): + """Get plane normal vector and point based on selected plane.""" + plane_type = self.params.get("plane_type", "Preset Plane") + + plane = self.params["plane"] + offset = self.params["offset"] + + if plane == "XY": + normal = App.Vector(0, 0, 1) + point = App.Vector(0, 0, offset) + elif plane == "XZ": + normal = App.Vector(0, 1, 0) + point = App.Vector(0, offset, 0) + elif plane == "YZ": + normal = App.Vector(1, 0, 0) + point = App.Vector(offset, 0, 0) + else: + normal = App.Vector(0, 0, 1) + point = App.Vector(0, 0, offset) + + return normal, point + + def cut_object(self): + """Cut the object along the specified plane.""" + normal, point = self.get_cut_plane_normal_and_point() + + bbox = self.shape.BoundBox + size = max(bbox.XLength, bbox.YLength, bbox.ZLength) * 3 + + half = size / 2 + box = Part.makeBox(size, size, size, App.Vector(-half, -half, 0)) + + z_axis = App.Vector(0, 0, 1) + rotation = App.Rotation(z_axis, normal) + + box = box.transformed(App.Matrix(rotation.toMatrix())) + box.translate(point) + + try: + bottom_part = self.shape.cut(box) + top_part = self.shape.common(box) + return bottom_part, top_part + except Exception as e: + raise HolePlacementError(f"Failed to cut object: {e!s}") from e + + def get_cut_face_center(self, part, normal): + """Find the center of the cut face on a part.""" + _, cut_point = self.get_cut_plane_normal_and_point() + + best_face = None + best_dist = float("inf") + + for face in part.Faces: + face_normal = face.normalAt(0, 0) + dot = abs(face_normal.dot(normal)) + if dot > 0.99: + face_center = face.CenterOfMass + dist_along_normal = abs((face_center - cut_point).dot(normal)) + if dist_along_normal < best_dist: + best_dist = dist_along_normal + best_face = face + + if best_face is not None: + return best_face.CenterOfMass + return None + + def is_hole_safe(self, center, direction, part, clearance=None): + """Check if a hole at this position would penetrate the outer surface.""" + diameter = self.params["diameter"] + depth = self.params["depth"] + if clearance is None: + clearance = self.params["clearance_min"] + + dir_normalized = App.Vector(direction).normalize() + radius_check = (diameter / 2) + clearance + start_offset = 0.5 + + start_pos = center + (dir_normalized * start_offset) + test_length = depth - start_offset + + if test_length <= 0: + return True + + test_cylinder = Part.makeCylinder( + radius_check, test_length, start_pos, dir_normalized + ) + + try: + intersection = part.common(test_cylinder) + cylinder_vol = test_cylinder.Volume + intersection_vol = intersection.Volume + + if intersection_vol < cylinder_vol * 0.95: + return False + + return True + except Exception: + return False + + def generate_hole_positions(self, cut_face_center, cut_face): + """Generate hole positions evenly distributed along the perimeter.""" + hole_count = self.params["hole_count"] + clearance = self.params["clearance_preferred"] + diameter = self.params["diameter"] + + wires = cut_face.Wires + if not wires: + return [], None, 0, [] + + outer_wire = max(wires, key=lambda w: w.Length) + perimeter_length = outer_wire.Length + + inset = clearance + (diameter / 2) + + normal, _ = self.get_cut_plane_normal_and_point() + normal = App.Vector(normal).normalize() + + if hole_count < 1: + return [], outer_wire, perimeter_length, [] + + spacing = perimeter_length / hole_count + + positions = [] + original_params = [] + + for i in range(hole_count): + param = (i * spacing) + (spacing / 2) + if param >= perimeter_length: + param = param - perimeter_length + + edge_point = self._get_point_at_length(outer_wire, param) + if edge_point is None: + continue + + inset_point = self._get_inset_point(edge_point, cut_face, normal, inset) + + if inset_point: + positions.append(inset_point) + original_params.append(param) + + return positions, outer_wire, perimeter_length, original_params + + def _get_point_at_length(self, wire, length): + """Get a point on the wire at a specific length along it.""" + cumulative_length = 0.0 + + for edge in wire.Edges: + edge_length = edge.Length + + if cumulative_length + edge_length >= length: + remaining = length - cumulative_length + param = remaining / edge_length if edge_length > 0 else 0 + + first_param = edge.FirstParameter + last_param = edge.LastParameter + edge_param = first_param + param * (last_param - first_param) + + try: + point = edge.valueAt(edge_param) + return App.Vector(point) + except Exception: + return None + + cumulative_length += edge_length + + return None + + def _get_inset_point(self, edge_point, cut_face, normal, inset): + """Get a point that is inset from the edge toward the face interior.""" + face_center = cut_face.CenterOfMass + + to_center = face_center - edge_point + to_center = to_center - normal * (to_center.dot(normal)) + + if to_center.Length < 0.001: + return None + + to_center_normalized = App.Vector(to_center).normalize() + inset_point = edge_point + (to_center_normalized * inset) + + try: + dist_info = cut_face.distToShape(Part.Vertex(inset_point)) + dist = dist_info[0] + + if dist < 0.5: + closest_on_face = dist_info[1][0][0] + return App.Vector(closest_on_face) + else: + dist_info = cut_face.distToShape(Part.Vertex(inset_point)) + return App.Vector(dist_info[1][0][0]) + except Exception: + return None + + def _check_hole_overlap(self, positions, new_pos): + """Check if a new hole position would overlap with existing holes.""" + diameter = self.params["diameter"] + min_distance = diameter * 2 + + for existing_pos in positions: + dist = (new_pos - existing_pos).Length + if dist < min_distance: + return False + + return True + + def execute(self, progress_callback=None): + """Execute the complete cutting and hole placement operation.""" + bottom_shape, top_shape = self.cut_object() + + normal, _ = self.get_cut_plane_normal_and_point() + + bottom_face_center = self.get_cut_face_center(bottom_shape, -normal) + top_face_center = self.get_cut_face_center(top_shape, normal) + + if not bottom_face_center or not top_face_center: + raise HolePlacementError("Could not find cut faces") + + bottom_cut_face = None + for face in bottom_shape.Faces: + if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1: + bottom_cut_face = face + break + + if not bottom_cut_face: + raise HolePlacementError("Could not find bottom cut face") + + top_cut_face = None + for face in top_shape.Faces: + if face.CenterOfMass.distanceToPoint(top_face_center) < 0.1: + top_cut_face = face + break + + if not top_cut_face: + raise HolePlacementError("Could not find top cut face") + + initial_positions, outer_wire, perimeter_length, original_params = ( + self.generate_hole_positions(bottom_face_center, bottom_cut_face) + ) + + if not initial_positions: + raise HolePlacementError("No valid hole positions found") + + clearance_preferred = self.params["clearance_preferred"] + validated_positions = [] + + for idx, pos in enumerate(initial_positions): + bottom_safe = self.is_hole_safe(pos, -normal, bottom_shape, clearance_preferred) + top_safe = self.is_hole_safe(pos, normal, top_shape, clearance_preferred) + + if bottom_safe and top_safe: + if self._check_hole_overlap(validated_positions, pos): + validated_positions.append(pos) + + if not validated_positions: + raise HolePlacementError("No valid hole positions found after validation") + + # Create PartDesign::Body objects from shapes + bottom_body = self._create_body_from_shape( + bottom_shape, f"{self.obj.Label}_Bottom" + ) + top_body = self._create_body_from_shape(top_shape, f"{self.obj.Label}_Top") + + # Find cut face names on the new bodies + bottom_face_name = self._find_cut_face_name(bottom_body, -normal) + top_face_name = self._find_cut_face_name(top_body, normal) + + # Create hole sketches with points at validated positions + bottom_sketch = self._create_hole_sketch( + bottom_body, bottom_face_name, validated_positions + ) + + top_sketch = self._create_hole_sketch( + top_body, top_face_name, validated_positions + ) + + # Create PartDesign::Hole features + self._create_hole_feature( + bottom_body, bottom_sketch, self.params["diameter"], self.params["depth"] + ) + + self._create_hole_feature( + top_body, top_sketch, self.params["diameter"], self.params["depth"] + ) + + return bottom_body, top_body + + def _create_body_from_shape(self, shape, name): + """Create a PartDesign::Body containing the given shape.""" + doc = App.ActiveDocument + + base_feature_name = f"{name}_Base" + feature = doc.addObject("Part::Feature", base_feature_name) + feature.Shape = shape + + body = doc.addObject("PartDesign::Body", name) + body.BaseFeature = feature + + if hasattr(feature, "ViewObject") and feature.ViewObject: + feature.ViewObject.Visibility = False + + doc.recompute() + return body + + def _find_cut_face_name(self, body, normal): + """Find the name of the cut face on a PartDesign::Body's Tip feature.""" + tip_feature = body.Tip + if tip_feature is None: + raise HolePlacementError(f"Body {body.Label} has no Tip feature") + + shape = tip_feature.Shape + target_normal = App.Vector(normal).normalize() + + best_face_idx = None + best_dot = -1 + + for i, face in enumerate(shape.Faces): + try: + face_normal = face.normalAt(0.5, 0.5) + dot = face_normal.dot(target_normal) + + if dot > best_dot: + best_dot = dot + best_face_idx = i + except Exception: + continue + + if best_face_idx is not None and best_dot > 0.99: + return f"Face{best_face_idx + 1}" + + raise HolePlacementError( + f"Could not find cut face on body {body.Label}. " + f"Best match had dot product {best_dot:.3f}" + ) + + def _world_to_sketch_coords(self, world_pos, sketch): + """Transform world coordinates to sketch-local 2D coordinates.""" + placement = sketch.Placement + inv_placement = placement.inverse() + local_pos = inv_placement.multVec(world_pos) + return App.Vector(local_pos.x, local_pos.y, 0) + + def _create_hole_sketch(self, body, cut_face_name, positions): + """Create a sketch with points at hole center positions.""" + sketch = body.newObject("Sketcher::SketchObject", "HoleCenters") + + tip_feature = body.Tip + if tip_feature is None: + raise HolePlacementError(f"Body {body.Label} has no Tip feature") + + sketch.AttachmentSupport = [(tip_feature, cut_face_name)] + sketch.MapMode = "FlatFace" + + App.ActiveDocument.recompute() + + for pos in positions: + local_pos = self._world_to_sketch_coords(pos, sketch) + sketch.addGeometry( + Part.Point(App.Vector(local_pos.x, local_pos.y, 0)), + False, + ) + + App.ActiveDocument.recompute() + return sketch + + def _create_hole_feature(self, body, sketch, diameter, depth): + """Create a PartDesign::Hole feature from a sketch with point geometry.""" + hole = body.newObject("PartDesign::Hole", "MagnetHoles") + hole.Profile = sketch + hole.Diameter = diameter + hole.Depth = depth + hole.DepthType = "Dimension" + hole.Threaded = False + hole.HoleCutType = "None" + + App.ActiveDocument.recompute() + return hole + + def _create_holes_boolean(self, part, direction, positions): + """Create holes using boolean operations (fallback method).""" + diameter = self.params["diameter"] + depth = self.params["depth"] + + dir_normalized = App.Vector(direction).normalize() + + result = part + holes_created = 0 + + for pos in positions: + offset = 0.1 + start_pos = pos - (dir_normalized * offset) + hole_length = depth + offset + + try: + hole = Part.makeCylinder( + diameter / 2, hole_length, start_pos, dir_normalized + ) + result = result.cut(hole) + holes_created += 1 + except Exception: + pass + + return result +''' + + +class TestCutSolidObjectPartDesignHole: + """Tests for cutting solid objects with PartDesign::Hole features.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if "CutMacroTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("CutMacroTestDoc") +doc = FreeCAD.newDocument("CutMacroTestDoc") +_result_ = True +""", + ) + + def test_cut_solid_box_with_partdesign_holes( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test cutting a solid box and creating PartDesign::Hole features.""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +doc = App.ActiveDocument + +# Create a solid box: 50x50x40mm +box = Part.makeBox(50, 50, 40) +box_obj = doc.addObject("Part::Feature", "TestBox") +box_obj.Shape = box +doc.recompute() + +# Create SmartCutter with parameters +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 20.0, # Cut in the middle + "diameter": 6.0, + "depth": 3.0, + "hole_count": 4, + "clearance_preferred": 2.0, + "clearance_min": 0.5, +} + +cutter = SmartCutter(box_obj, params) + +# Execute the cut +bottom_body, top_body = cutter.execute() + +doc.recompute() + +# Verify results +_result_ = { + "success": True, + "bottom_body_type": bottom_body.TypeId, + "top_body_type": top_body.TypeId, + "bottom_body_name": bottom_body.Label, + "top_body_name": top_body.Label, + "bottom_has_tip": bottom_body.Tip is not None, + "top_has_tip": top_body.Tip is not None, + "bottom_volume": bottom_body.Shape.Volume, + "top_volume": top_body.Shape.Volume, + "bottom_valid": bottom_body.Shape.isValid(), + "top_valid": top_body.Shape.isValid(), +} + +# Check for PartDesign::Hole features +for obj in bottom_body.Group: + if obj.TypeId == "PartDesign::Hole": + _result_["bottom_has_hole_feature"] = True + _result_["bottom_hole_diameter"] = obj.Diameter.Value + _result_["bottom_hole_depth"] = obj.Depth.Value + break +else: + _result_["bottom_has_hole_feature"] = False + +for obj in top_body.Group: + if obj.TypeId == "PartDesign::Hole": + _result_["top_has_hole_feature"] = True + _result_["top_hole_diameter"] = obj.Diameter.Value + _result_["top_hole_depth"] = obj.Depth.Value + break +else: + _result_["top_has_hole_feature"] = False +""", + ) + + assert result["result"]["success"] is True + assert result["result"]["bottom_body_type"] == "PartDesign::Body" + assert result["result"]["top_body_type"] == "PartDesign::Body" + assert "Bottom" in result["result"]["bottom_body_name"] + assert "Top" in result["result"]["top_body_name"] + assert result["result"]["bottom_has_tip"] is True + assert result["result"]["top_has_tip"] is True + assert result["result"]["bottom_valid"] is True + assert result["result"]["top_valid"] is True + # Check PartDesign::Hole features exist + assert result["result"]["bottom_has_hole_feature"] is True + assert result["result"]["top_has_hole_feature"] is True + # Check hole parameters + assert result["result"]["bottom_hole_diameter"] == pytest.approx(6.0, abs=0.1) + assert result["result"]["bottom_hole_depth"] == pytest.approx(3.0, abs=0.1) + assert result["result"]["top_hole_diameter"] == pytest.approx(6.0, abs=0.1) + assert result["result"]["top_hole_depth"] == pytest.approx(3.0, abs=0.1) + # Volumes should be roughly half (minus hole volume) + assert result["result"]["bottom_volume"] > 0 + assert result["result"]["top_volume"] > 0 + + +class TestCutHollowObjectPartDesignHole: + """Tests for cutting hollow objects with PartDesign::Hole features.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if "CutHollowTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("CutHollowTestDoc") +doc = FreeCAD.newDocument("CutHollowTestDoc") +_result_ = True +""", + ) + + def test_cut_hollow_cylinder_with_partdesign_holes( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test cutting a hollow cylinder (vase shape) with PartDesign::Hole features.""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +import math + +doc = App.ActiveDocument + +# Create a hollow vase-shaped cylinder +# Outer cylinder: radius 20mm, height 60mm +# Inner cylinder: radius 13mm (wall thickness 7mm), height 55mm (5mm bottom) +outer_radius = 20.0 +inner_radius = 13.0 +height = 60.0 +bottom_thickness = 5.0 + +# Create outer cylinder +outer = Part.makeCylinder(outer_radius, height) + +# Create inner cylinder (starting above bottom) +inner = Part.makeCylinder(inner_radius, height - bottom_thickness, App.Vector(0, 0, bottom_thickness)) + +# Cut inner from outer to make hollow vase +vase_shape = outer.cut(inner) + +vase_obj = doc.addObject("Part::Feature", "TestVase") +vase_obj.Shape = vase_shape +doc.recompute() + +# Create SmartCutter with parameters +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 30.0, # Cut at 30mm height (middle of vase) + "diameter": 3.0, # Smaller holes for thin walls + "depth": 3.0, + "hole_count": 6, + "clearance_preferred": 2.0, + "clearance_min": 0.5, +} + +cutter = SmartCutter(vase_obj, params) + +# Execute the cut +bottom_body, top_body = cutter.execute() + +doc.recompute() + +# Verify results +_result_ = { + "success": True, + "bottom_body_type": bottom_body.TypeId, + "top_body_type": top_body.TypeId, + "bottom_valid": bottom_body.Shape.isValid(), + "top_valid": top_body.Shape.isValid(), + "bottom_volume": bottom_body.Shape.Volume, + "top_volume": top_body.Shape.Volume, +} + +# Check for PartDesign::Hole features +hole_count_bottom = 0 +hole_count_top = 0 + +for obj in bottom_body.Group: + if obj.TypeId == "PartDesign::Hole": + _result_["bottom_has_hole_feature"] = True + _result_["bottom_hole_diameter"] = obj.Diameter.Value + hole_count_bottom += 1 + +for obj in top_body.Group: + if obj.TypeId == "PartDesign::Hole": + _result_["top_has_hole_feature"] = True + _result_["top_hole_diameter"] = obj.Diameter.Value + hole_count_top += 1 + +_result_["bottom_hole_feature_count"] = hole_count_bottom +_result_["top_hole_feature_count"] = hole_count_top + +# Default to False if no holes found +if "bottom_has_hole_feature" not in _result_: + _result_["bottom_has_hole_feature"] = False +if "top_has_hole_feature" not in _result_: + _result_["top_has_hole_feature"] = False +""", + ) + + assert result["result"]["success"] is True + assert result["result"]["bottom_body_type"] == "PartDesign::Body" + assert result["result"]["top_body_type"] == "PartDesign::Body" + assert result["result"]["bottom_valid"] is True + assert result["result"]["top_valid"] is True + # Check PartDesign::Hole features exist + assert result["result"]["bottom_has_hole_feature"] is True + assert result["result"]["top_has_hole_feature"] is True + # Each body should have one Hole feature (covering all points in sketch) + assert result["result"]["bottom_hole_feature_count"] >= 1 + assert result["result"]["top_hole_feature_count"] >= 1 + # Verify hole diameter + assert result["result"]["bottom_hole_diameter"] == pytest.approx(3.0, abs=0.1) + assert result["result"]["top_hole_diameter"] == pytest.approx(3.0, abs=0.1) + + +class TestBooleanHoleFallback: + """Tests for the fallback boolean hole creation method.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if "BooleanHoleTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("BooleanHoleTestDoc") +doc = FreeCAD.newDocument("BooleanHoleTestDoc") +_result_ = True +""", + ) + + def test_boolean_hole_creation_fallback( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test the _create_holes_boolean fallback method directly.""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +doc = App.ActiveDocument + +# Create a solid box: 50x50x20mm +box = Part.makeBox(50, 50, 20) +box_obj = doc.addObject("Part::Feature", "TestBox") +box_obj.Shape = box +doc.recompute() + +# Calculate initial volume +initial_volume = box.Volume + +# Create SmartCutter with parameters +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 10.0, # Cut in the middle + "diameter": 5.0, + "depth": 5.0, + "hole_count": 4, + "clearance_preferred": 2.0, + "clearance_min": 0.5, +} + +cutter = SmartCutter(box_obj, params) + +# First cut the object to get the two halves +bottom_shape, top_shape = cutter.cut_object() + +# Get the cut face center and face for hole position generation +normal, _ = cutter.get_cut_plane_normal_and_point() +bottom_face_center = cutter.get_cut_face_center(bottom_shape, -normal) + +# Find the actual bottom cut face +bottom_cut_face = None +for face in bottom_shape.Faces: + if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1: + bottom_cut_face = face + break + +# Generate hole positions +positions, _, _, _ = cutter.generate_hole_positions(bottom_face_center, bottom_cut_face) + +# Apply boolean hole method to bottom shape +# Note: holes go in -normal direction (into the bottom part) +bottom_with_holes = cutter._create_holes_boolean(bottom_shape, -normal, positions) + +# Calculate expected volume reduction per hole +import math +hole_volume = math.pi * (params["diameter"] / 2) ** 2 * params["depth"] +expected_reduction = hole_volume * len(positions) + +# Create Part::Feature objects for the results +bottom_result = doc.addObject("Part::Feature", "BottomWithHoles") +bottom_result.Shape = bottom_with_holes +doc.recompute() + +# Verify results +_result_ = { + "success": True, + "initial_volume": initial_volume, + "bottom_half_volume_before": bottom_shape.Volume, + "bottom_with_holes_volume": bottom_with_holes.Volume, + "positions_count": len(positions), + "hole_volume_each": hole_volume, + "expected_volume_reduction": expected_reduction, + "actual_volume_reduction": bottom_shape.Volume - bottom_with_holes.Volume, + "bottom_valid": bottom_with_holes.isValid(), + "result_type": bottom_result.TypeId, +} + +# Volume should have decreased by approximately the hole volumes +volume_diff = abs(_result_["actual_volume_reduction"] - expected_reduction) +_result_["volume_reduction_accurate"] = volume_diff < 1.0 # Within 1 mm^3 tolerance +""", + ) + + assert result["result"]["success"] is True + assert result["result"]["bottom_valid"] is True + assert ( + result["result"]["result_type"] == "Part::Feature" + ) # Boolean result is Part::Feature, not PartDesign + assert result["result"]["positions_count"] >= 1 + # Volume should have decreased + assert ( + result["result"]["bottom_with_holes_volume"] + < result["result"]["bottom_half_volume_before"] + ) + # Volume reduction should be close to expected + assert result["result"]["volume_reduction_accurate"] is True + + def test_boolean_vs_partdesign_comparison( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Compare boolean and PartDesign hole methods produce similar results.""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +doc = App.ActiveDocument + +# Create two identical solid boxes +box1 = Part.makeBox(50, 50, 20) +box1_obj = doc.addObject("Part::Feature", "Box1") +box1_obj.Shape = box1 + +box2 = Part.makeBox(50, 50, 20) +box2_obj = doc.addObject("Part::Feature", "Box2") +box2_obj.Shape = box2 + +doc.recompute() + +# Same parameters for both +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 10.0, + "diameter": 5.0, + "depth": 5.0, + "hole_count": 4, + "clearance_preferred": 3.0, + "clearance_min": 1.0, +} + +# Test 1: PartDesign method +cutter1 = SmartCutter(box1_obj, params) +pd_bottom, pd_top = cutter1.execute() + +# Test 2: Boolean method (manual process) +cutter2 = SmartCutter(box2_obj, params) +bottom_shape, top_shape = cutter2.cut_object() + +normal, _ = cutter2.get_cut_plane_normal_and_point() +bottom_face_center = cutter2.get_cut_face_center(bottom_shape, -normal) + +bottom_cut_face = None +for face in bottom_shape.Faces: + if face.CenterOfMass.distanceToPoint(bottom_face_center) < 0.1: + bottom_cut_face = face + break + +positions, _, _, _ = cutter2.generate_hole_positions(bottom_face_center, bottom_cut_face) + +bool_bottom = cutter2._create_holes_boolean(bottom_shape, -normal, positions) +bool_top = cutter2._create_holes_boolean(top_shape, normal, positions) + +# Create result objects for boolean method +bool_bottom_obj = doc.addObject("Part::Feature", "BoolBottom") +bool_bottom_obj.Shape = bool_bottom +bool_top_obj = doc.addObject("Part::Feature", "BoolTop") +bool_top_obj.Shape = bool_top + +doc.recompute() + +# Compare results +pd_bottom_vol = pd_bottom.Shape.Volume +pd_top_vol = pd_top.Shape.Volume +bool_bottom_vol = bool_bottom.Volume +bool_top_vol = bool_top.Volume + +_result_ = { + "success": True, + "partdesign_bottom_volume": pd_bottom_vol, + "partdesign_top_volume": pd_top_vol, + "boolean_bottom_volume": bool_bottom_vol, + "boolean_top_volume": bool_top_vol, + "partdesign_bottom_type": pd_bottom.TypeId, + "boolean_bottom_type": bool_bottom_obj.TypeId, + "volumes_match": abs(pd_bottom_vol - bool_bottom_vol) < 5.0 and abs(pd_top_vol - bool_top_vol) < 5.0, + "pd_bottom_valid": pd_bottom.Shape.isValid(), + "pd_top_valid": pd_top.Shape.isValid(), + "bool_bottom_valid": bool_bottom.isValid(), + "bool_top_valid": bool_top.isValid(), +} +""", + ) + + assert result["result"]["success"] is True + # Both methods should produce valid shapes + assert result["result"]["pd_bottom_valid"] is True + assert result["result"]["pd_top_valid"] is True + assert result["result"]["bool_bottom_valid"] is True + assert result["result"]["bool_top_valid"] is True + # PartDesign produces Body objects, boolean produces Part::Feature + assert result["result"]["partdesign_bottom_type"] == "PartDesign::Body" + assert result["result"]["boolean_bottom_type"] == "Part::Feature" + # Volumes should be similar (within tolerance for floating point differences) + assert result["result"]["volumes_match"] is True + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if "EdgeCaseTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("EdgeCaseTestDoc") +doc = FreeCAD.newDocument("EdgeCaseTestDoc") +_result_ = True +""", + ) + + def test_single_hole(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test with just one hole requested.""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +doc = App.ActiveDocument + +box = Part.makeBox(50, 50, 40) +box_obj = doc.addObject("Part::Feature", "TestBox") +box_obj.Shape = box +doc.recompute() + +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 20.0, + "diameter": 6.0, + "depth": 3.0, + "hole_count": 1, # Single hole + "clearance_preferred": 2.0, + "clearance_min": 0.5, +} + +cutter = SmartCutter(box_obj, params) +bottom_body, top_body = cutter.execute() + +doc.recompute() + +# Count geometry points in the sketches +bottom_sketch = None +for obj in bottom_body.Group: + if obj.TypeId == "Sketcher::SketchObject": + bottom_sketch = obj + break + +_result_ = { + "success": True, + "bottom_valid": bottom_body.Shape.isValid(), + "top_valid": top_body.Shape.isValid(), + "sketch_geometry_count": bottom_sketch.GeometryCount if bottom_sketch else 0, +} +""", + ) + + assert result["result"]["success"] is True + assert result["result"]["bottom_valid"] is True + assert result["result"]["top_valid"] is True + # Should have exactly 1 point in sketch for single hole + assert result["result"]["sketch_geometry_count"] == 1 + + def test_many_holes(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test with many holes requested (some may be skipped due to overlap).""" + result = execute_code( + xmlrpc_proxy, + SMART_CUTTER_CODE + + """ +doc = App.ActiveDocument + +# Create a larger box to fit more holes +box = Part.makeBox(100, 100, 40) +box_obj = doc.addObject("Part::Feature", "TestBox") +box_obj.Shape = box +doc.recompute() + +params = { + "plane_type": "Preset Plane", + "plane": "XY", + "offset": 20.0, + "diameter": 4.0, + "depth": 3.0, + "hole_count": 20, # Many holes + "clearance_preferred": 2.0, + "clearance_min": 0.5, +} + +cutter = SmartCutter(box_obj, params) +bottom_body, top_body = cutter.execute() + +doc.recompute() + +# Count geometry points in the sketches +bottom_sketch = None +for obj in bottom_body.Group: + if obj.TypeId == "Sketcher::SketchObject": + bottom_sketch = obj + break + +_result_ = { + "success": True, + "bottom_valid": bottom_body.Shape.isValid(), + "top_valid": top_body.Shape.isValid(), + "sketch_geometry_count": bottom_sketch.GeometryCount if bottom_sketch else 0, +} +""", + ) + + assert result["result"]["success"] is True + assert result["result"]["bottom_valid"] is True + assert result["result"]["top_valid"] is True + # Should have multiple holes (exact count depends on overlap checking) + assert result["result"]["sketch_geometry_count"] >= 1 diff --git a/tests/integration/test_gui_mode.py b/tests/integration/test_gui_mode.py new file mode 100644 index 0000000..2f2ba4e --- /dev/null +++ b/tests/integration/test_gui_mode.py @@ -0,0 +1,579 @@ +"""Integration tests for FreeCAD MCP GUI mode. + +These tests verify that the MCP bridge works correctly when FreeCAD is running +in GUI mode (with full graphical interface). They test GUI-specific features +like visibility, display modes, colors, and camera operations. + +Note: These tests require a running FreeCAD GUI server. + Start it with: just run-gui + +To run these tests: + pytest tests/integration/test_gui_mode.py -v +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + import xmlrpc.client + from collections.abc import Generator + +# Mark all tests in this module as integration tests and gui tests +pytestmark = [pytest.mark.integration, pytest.mark.gui] + +# Note: xmlrpc_proxy fixture is defined in conftest.py + + +@pytest.fixture(scope="module") +def temp_dir() -> Generator[str, None, None]: + """Create a temporary directory for test files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]: + """Execute Python code via the MCP bridge and return the result.""" + result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment] + assert result.get("success"), f"Execution failed: {result.get('error_traceback')}" + return result + + +class TestGUIConnection: + """Tests for GUI mode connectivity and detection.""" + + def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test that the server responds to ping.""" + result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment] + assert result["pong"] is True + assert "timestamp" in result + + def test_gui_mode_detected(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test that FreeCAD is running in GUI mode (GuiUp=True).""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +_result_ = {"gui_up": FreeCAD.GuiUp} +""", + ) + # In GUI mode, GuiUp should be True (1) + assert result["result"]["gui_up"] == 1 or result["result"]["gui_up"] is True + + +class TestGUIObjectCreation: + """Tests for object creation in GUI mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +# Close any existing GUITestDoc +if "GUITestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("GUITestDoc") +doc = FreeCAD.newDocument("GUITestDoc") +_result_ = True +""", + ) + + def test_create_box_with_view_object( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test creating a box with ViewObject in GUI mode.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument +box = Part.makeBox(10, 20, 30) +obj = doc.addObject("Part::Feature", "GUIBox") +obj.Shape = box +doc.recompute() + +has_view_object = hasattr(obj, "ViewObject") and obj.ViewObject is not None + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "has_view_object": has_view_object, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "GUIBox" + assert abs(result["result"]["volume"] - 6000.0) < 0.01 + assert result["result"]["has_view_object"] is True + assert result["result"]["valid"] is True + + +class TestVisibility: + """Tests for object visibility operations in GUI mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a document with test objects.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "VisibilityTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("VisibilityTestDoc") +doc = FreeCAD.newDocument("VisibilityTestDoc") + +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "VisBox") +obj.Shape = box +doc.recompute() +_result_ = True +""", + ) + + def test_hide_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test hiding an object.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("VisBox") + +# Hide the object +obj.ViewObject.Visibility = False + +_result_ = { + "visible": obj.ViewObject.Visibility +} +""", + ) + assert result["result"]["visible"] is False + + def test_show_object(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test showing an object.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("VisBox") + +# First hide, then show +obj.ViewObject.Visibility = False +obj.ViewObject.Visibility = True + +_result_ = { + "visible": obj.ViewObject.Visibility +} +""", + ) + assert result["result"]["visible"] is True + + +class TestDisplayMode: + """Tests for display mode operations in GUI mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a document with test objects.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "DisplayModeTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("DisplayModeTestDoc") +doc = FreeCAD.newDocument("DisplayModeTestDoc") + +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "DisplayBox") +obj.Shape = box +doc.recompute() +_result_ = True +""", + ) + + def test_get_display_modes(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test getting available display modes.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("DisplayBox") + +modes = obj.ViewObject.listDisplayModes() + +_result_ = { + "modes": list(modes), + "current": obj.ViewObject.DisplayMode +} +""", + ) + # Typical modes: "Flat Lines", "Shaded", "Wireframe", etc. + assert len(result["result"]["modes"]) > 0 + assert result["result"]["current"] is not None + + def test_set_display_mode(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test setting display mode.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("DisplayBox") + +modes = obj.ViewObject.listDisplayModes() +# Set to a different mode if available +target_mode = "Wireframe" if "Wireframe" in modes else modes[0] +obj.ViewObject.DisplayMode = target_mode + +_result_ = { + "mode_set": obj.ViewObject.DisplayMode, + "target_mode": target_mode +} +""", + ) + assert result["result"]["mode_set"] == result["result"]["target_mode"] + + +class TestObjectColor: + """Tests for object color operations in GUI mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a document with test objects.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "ColorTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("ColorTestDoc") +doc = FreeCAD.newDocument("ColorTestDoc") + +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "ColorBox") +obj.Shape = box +doc.recompute() +_result_ = True +""", + ) + + def test_set_shape_color(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test setting object shape color.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("ColorBox") + +# Set color to red (RGB as tuple of floats 0-1) +obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0) + +color = obj.ViewObject.ShapeColor + +_result_ = { + "r": color[0], + "g": color[1], + "b": color[2] +} +""", + ) + assert result["result"]["r"] == pytest.approx(1.0, rel=0.01) + assert result["result"]["g"] == pytest.approx(0.0, abs=0.01) + assert result["result"]["b"] == pytest.approx(0.0, abs=0.01) + + def test_set_transparency(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test setting object transparency.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("ColorBox") + +# Set 50% transparency +obj.ViewObject.Transparency = 50 + +_result_ = { + "transparency": obj.ViewObject.Transparency +} +""", + ) + assert result["result"]["transparency"] == 50 + + +class TestCameraOperations: + """Tests for camera/view operations in GUI mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a document with test objects.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "CameraTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("CameraTestDoc") +doc = FreeCAD.newDocument("CameraTestDoc") + +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "CameraBox") +obj.Shape = box +doc.recompute() +_result_ = True +""", + ) + + def test_fit_all(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test fit all view operation.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import FreeCADGui + +doc = FreeCAD.ActiveDocument +view = FreeCADGui.ActiveDocument.ActiveView + +# Fit all objects in view +view.fitAll() + +_result_ = {"success": True} +""", + ) + assert result["result"]["success"] is True + + def test_set_view_direction(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test setting view direction (front, top, etc.).""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import FreeCADGui + +doc = FreeCAD.ActiveDocument +view = FreeCADGui.ActiveDocument.ActiveView + +# Set to front view +view.viewFront() + +# Get camera orientation +cam = view.getCameraOrientation() + +_result_ = { + "success": True, + "camera_orientation": [cam.Q[0], cam.Q[1], cam.Q[2], cam.Q[3]] +} +""", + ) + assert result["result"]["success"] is True + + +class TestScreenshot: + """Tests for screenshot functionality in GUI mode.""" + + def test_capture_screenshot( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str + ) -> None: + """Test capturing a screenshot.""" + # First create a document with objects + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "ScreenshotTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("ScreenshotTestDoc") +doc = FreeCAD.newDocument("ScreenshotTestDoc") + +box = Part.makeBox(20, 20, 20) +obj = doc.addObject("Part::Feature", "ScreenshotBox") +obj.Shape = box +obj.ViewObject.ShapeColor = (0.0, 0.5, 1.0) # Blue color +doc.recompute() +_result_ = True +""", + ) + + screenshot_path = Path(temp_dir) / "test_screenshot.png" + + result = execute_code( + xmlrpc_proxy, + f""" +import FreeCAD +import FreeCADGui +import os + +doc = FreeCAD.ActiveDocument +view = FreeCADGui.ActiveDocument.ActiveView + +# Fit objects in view +view.fitAll() + +# Save screenshot +view.saveImage({screenshot_path!r}, 800, 600, "White") + +_result_ = {{ + "saved": os.path.exists({screenshot_path!r}), + "path": {screenshot_path!r} +}} +""", + ) + assert result["result"]["saved"] is True + assert screenshot_path.exists() + # Verify file has content + assert screenshot_path.stat().st_size > 0 + + +class TestComplexGUIWorkflow: + """Tests for complex workflows that combine GUI and modeling features.""" + + def test_create_assembly_with_colors( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test creating multiple objects with different colors.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "AssemblyTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("AssemblyTestDoc") +doc = FreeCAD.newDocument("AssemblyTestDoc") + +# Create base plate (gray) +base = Part.makeBox(100, 100, 5) +base_obj = doc.addObject("Part::Feature", "BasePlate") +base_obj.Shape = base +base_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5) + +# Create pillar 1 (red) +pillar1 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 10, 5)) +pillar1_obj = doc.addObject("Part::Feature", "Pillar1") +pillar1_obj.Shape = pillar1 +pillar1_obj.ViewObject.ShapeColor = (1.0, 0.0, 0.0) + +# Create pillar 2 (green) +pillar2 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 10, 5)) +pillar2_obj = doc.addObject("Part::Feature", "Pillar2") +pillar2_obj.Shape = pillar2 +pillar2_obj.ViewObject.ShapeColor = (0.0, 1.0, 0.0) + +# Create pillar 3 (blue) +pillar3 = Part.makeBox(10, 10, 50, FreeCAD.Vector(80, 80, 5)) +pillar3_obj = doc.addObject("Part::Feature", "Pillar3") +pillar3_obj.Shape = pillar3 +pillar3_obj.ViewObject.ShapeColor = (0.0, 0.0, 1.0) + +# Create pillar 4 (yellow) +pillar4 = Part.makeBox(10, 10, 50, FreeCAD.Vector(10, 80, 5)) +pillar4_obj = doc.addObject("Part::Feature", "Pillar4") +pillar4_obj.Shape = pillar4 +pillar4_obj.ViewObject.ShapeColor = (1.0, 1.0, 0.0) + +# Create top plate (semi-transparent gray) +top = Part.makeBox(100, 100, 5, FreeCAD.Vector(0, 0, 55)) +top_obj = doc.addObject("Part::Feature", "TopPlate") +top_obj.Shape = top +top_obj.ViewObject.ShapeColor = (0.5, 0.5, 0.5) +top_obj.ViewObject.Transparency = 30 + +doc.recompute() + +_result_ = { + "object_count": len(doc.Objects), + "objects": [obj.Name for obj in doc.Objects], + "all_visible": all(obj.ViewObject.Visibility for obj in doc.Objects) +} +""", + ) + assert result["result"]["object_count"] == 6 + assert "BasePlate" in result["result"]["objects"] + assert "TopPlate" in result["result"]["objects"] + assert result["result"]["all_visible"] is True + + def test_export_with_screenshot( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str + ) -> None: + """Test creating model, setting view, taking screenshot, and exporting.""" + step_path = Path(temp_dir) / "workflow_export.step" + screenshot_path = Path(temp_dir) / "workflow_screenshot.png" + + result = execute_code( + xmlrpc_proxy, + f""" +import FreeCAD +import FreeCADGui +import Part +import os + +if "WorkflowTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("WorkflowTestDoc") +doc = FreeCAD.newDocument("WorkflowTestDoc") + +# Create a simple bracket shape +# Base +base = Part.makeBox(50, 30, 5) +# Vertical support +vertical = Part.makeBox(5, 30, 40, FreeCAD.Vector(0, 0, 5)) +# Top flange with hole +top_flange = Part.makeBox(20, 30, 5, FreeCAD.Vector(0, 0, 45)) +hole = Part.makeCylinder(4, 10, FreeCAD.Vector(10, 15, 40)) + +# Combine and cut +bracket = base.fuse(vertical).fuse(top_flange).cut(hole) +bracket_obj = doc.addObject("Part::Feature", "Bracket") +bracket_obj.Shape = bracket +bracket_obj.ViewObject.ShapeColor = (0.2, 0.4, 0.8) + +doc.recompute() + +# Set isometric view +view = FreeCADGui.ActiveDocument.ActiveView +view.viewIsometric() +view.fitAll() + +# Take screenshot +view.saveImage({screenshot_path!r}, 800, 600, "White") + +# Export to STEP +bracket_obj.Shape.exportStep({step_path!r}) + +_result_ = {{ + "bracket_valid": bracket_obj.Shape.isValid(), + "bracket_volume": bracket_obj.Shape.Volume, + "screenshot_exists": os.path.exists({screenshot_path!r}), + "step_exists": os.path.exists({step_path!r}) +}} +""", + ) + assert result["result"]["bracket_valid"] is True + assert result["result"]["bracket_volume"] > 0 + assert result["result"]["screenshot_exists"] is True + assert result["result"]["step_exists"] is True + assert screenshot_path.exists() + assert step_path.exists() diff --git a/tests/integration/test_headless_mode.py b/tests/integration/test_headless_mode.py new file mode 100644 index 0000000..c72a6b6 --- /dev/null +++ b/tests/integration/test_headless_mode.py @@ -0,0 +1,624 @@ +"""Integration tests for FreeCAD MCP headless mode. + +These tests verify that the MCP bridge works correctly when FreeCAD is running +in headless mode (without GUI). They test object creation, manipulation, and +export functionality. + +Note: These tests require a running FreeCAD headless server. + Start it with: just run-headless + +To run these tests: + pytest tests/integration/test_headless_mode.py -v +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest + +if TYPE_CHECKING: + import xmlrpc.client + from collections.abc import Generator + +# Mark all tests in this module as integration tests +pytestmark = pytest.mark.integration + +# Note: xmlrpc_proxy fixture is defined in conftest.py + + +@pytest.fixture(scope="module") +def temp_dir() -> Generator[str, None, None]: + """Create a temporary directory for test files.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]: + """Execute Python code via the MCP bridge and return the result.""" + result: dict[str, Any] = proxy.execute(code) # type: ignore[assignment] + assert result.get("success"), f"Execution failed: {result.get('error_traceback')}" + return result + + +class TestHeadlessConnection: + """Tests for basic headless mode connectivity.""" + + def test_ping(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test that the server responds to ping.""" + result: dict[str, Any] = xmlrpc_proxy.ping() # type: ignore[assignment] + assert result["pong"] is True + assert "timestamp" in result + + def test_headless_mode_detected( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test that FreeCAD is running in headless mode (GuiUp=False).""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +_result_ = {"gui_up": FreeCAD.GuiUp} +""", + ) + # In headless mode, GuiUp should be False (0) + assert result["result"]["gui_up"] == 0 or result["result"]["gui_up"] is False + + +class TestDocumentManagement: + """Tests for document creation and management in headless mode.""" + + def test_create_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a new document.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +doc = FreeCAD.newDocument("TestDoc") +_result_ = {"name": doc.Name, "object_count": len(doc.Objects)} +""", + ) + assert result["result"]["name"] == "TestDoc" + assert result["result"]["object_count"] == 0 + + def test_list_documents(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test listing open documents.""" + # First create a document + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if not FreeCAD.listDocuments(): + FreeCAD.newDocument("ListTestDoc") +_result_ = True +""", + ) + + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +docs = list(FreeCAD.listDocuments().keys()) +_result_ = {"documents": docs, "count": len(docs)} +""", + ) + assert result["result"]["count"] >= 1 + + def test_close_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test closing a document.""" + # Create a document to close + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +doc = FreeCAD.newDocument("ToClose") +_result_ = True +""", + ) + + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +FreeCAD.closeDocument("ToClose") +_result_ = {"closed": "ToClose" not in FreeCAD.listDocuments()} +""", + ) + assert result["result"]["closed"] is True + + +class TestPrimitiveCreation: + """Tests for creating primitive shapes in headless mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +# Close any existing PrimitiveTestDoc +if "PrimitiveTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("PrimitiveTestDoc") +doc = FreeCAD.newDocument("PrimitiveTestDoc") +_result_ = True +""", + ) + + def test_create_box(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a Part::Box primitive.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument +box = Part.makeBox(10, 20, 30) +obj = doc.addObject("Part::Feature", "TestBox") +obj.Shape = box +doc.recompute() + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "TestBox" + # Volume should be 10 * 20 * 30 = 6000 + assert abs(result["result"]["volume"] - 6000.0) < 0.01 + assert result["result"]["valid"] is True + + def test_create_cylinder(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a Part::Cylinder primitive.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part +import math + +doc = FreeCAD.ActiveDocument +cylinder = Part.makeCylinder(5, 20) +obj = doc.addObject("Part::Feature", "TestCylinder") +obj.Shape = cylinder +doc.recompute() + +expected_volume = math.pi * 5**2 * 20 + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "expected_volume": expected_volume, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "TestCylinder" + # Volume should be π * r² * h = π * 25 * 20 ≈ 1570.8 + assert ( + abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 0.1 + ) + assert result["result"]["valid"] is True + + def test_create_sphere(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a Part::Sphere primitive.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part +import math + +doc = FreeCAD.ActiveDocument +sphere = Part.makeSphere(10) +obj = doc.addObject("Part::Feature", "TestSphere") +obj.Shape = sphere +doc.recompute() + +expected_volume = (4/3) * math.pi * 10**3 + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "expected_volume": expected_volume, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "TestSphere" + # Volume should be (4/3) * π * r³ ≈ 4188.79 + assert ( + abs(result["result"]["volume"] - result["result"]["expected_volume"]) < 1.0 + ) + assert result["result"]["valid"] is True + + def test_create_cone(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a Part::Cone primitive.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument +cone = Part.makeCone(10, 0, 20) # Base radius=10, top radius=0, height=20 +obj = doc.addObject("Part::Feature", "TestCone") +obj.Shape = cone +doc.recompute() + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "TestCone" + # Volume of a cone = (1/3) * π * r² * h ≈ 2094.4 + assert result["result"]["volume"] > 2000 + assert result["result"]["valid"] is True + + def test_create_torus(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test creating a Part::Torus primitive.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument +torus = Part.makeTorus(20, 5) # Major radius=20, minor radius=5 +obj = doc.addObject("Part::Feature", "TestTorus") +obj.Shape = torus +doc.recompute() + +_result_ = { + "name": obj.Name, + "volume": obj.Shape.Volume, + "valid": obj.Shape.isValid() +} +""", + ) + assert result["result"]["name"] == "TestTorus" + assert result["result"]["volume"] > 0 + assert result["result"]["valid"] is True + + +class TestBooleanOperations: + """Tests for boolean operations in headless mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document for each test.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +if "BooleanTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("BooleanTestDoc") +doc = FreeCAD.newDocument("BooleanTestDoc") +_result_ = True +""", + ) + + def test_boolean_fuse(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test boolean fuse (union) operation.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument + +# Create two overlapping boxes +box1 = Part.makeBox(10, 10, 10) +box2 = Part.makeBox(10, 10, 10, FreeCAD.Vector(5, 0, 0)) + +obj1 = doc.addObject("Part::Feature", "Box1") +obj1.Shape = box1 +obj2 = doc.addObject("Part::Feature", "Box2") +obj2.Shape = box2 + +# Fuse them +fused = box1.fuse(box2) +obj_fused = doc.addObject("Part::Feature", "Fused") +obj_fused.Shape = fused +doc.recompute() + +# Fused volume should be less than 2000 (two boxes) due to overlap +_result_ = { + "fused_volume": obj_fused.Shape.Volume, + "valid": obj_fused.Shape.isValid() +} +""", + ) + # Two 10x10x10 boxes overlapping by 5mm = 2000 - 500 = 1500 + assert result["result"]["fused_volume"] == pytest.approx(1500.0, rel=0.01) + assert result["result"]["valid"] is True + + def test_boolean_cut(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Test boolean cut (subtract) operation.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +doc = FreeCAD.ActiveDocument + +# Create a box and a cylinder to cut from it +box = Part.makeBox(20, 20, 20) +cylinder = Part.makeCylinder(5, 30, FreeCAD.Vector(10, 10, -5)) + +# Cut cylinder from box +cut = box.cut(cylinder) +obj_cut = doc.addObject("Part::Feature", "Cut") +obj_cut.Shape = cut +doc.recompute() + +box_volume = 20 * 20 * 20 +import math +cylinder_volume_in_box = math.pi * 5**2 * 20 # Only 20mm of cylinder is in box + +_result_ = { + "cut_volume": obj_cut.Shape.Volume, + "expected_volume": box_volume - cylinder_volume_in_box, + "valid": obj_cut.Shape.isValid() +} +""", + ) + # Volume should be box - cylinder intersection + assert ( + abs(result["result"]["cut_volume"] - result["result"]["expected_volume"]) + < 10 + ) + assert result["result"]["valid"] is True + + +class TestObjectManipulation: + """Tests for object manipulation in headless mode.""" + + @pytest.fixture(autouse=True) + def setup_document(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: + """Create a fresh document with a test box.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "ManipTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("ManipTestDoc") +doc = FreeCAD.newDocument("ManipTestDoc") + +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "ManipBox") +obj.Shape = box +doc.recompute() +_result_ = True +""", + ) + + def test_set_placement_position( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test setting object position.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("ManipBox") + +# Move the object +obj.Placement.Base = FreeCAD.Vector(100, 200, 300) + +_result_ = { + "x": obj.Placement.Base.x, + "y": obj.Placement.Base.y, + "z": obj.Placement.Base.z +} +""", + ) + assert result["result"]["x"] == 100.0 + assert result["result"]["y"] == 200.0 + assert result["result"]["z"] == 300.0 + + def test_set_placement_rotation( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test setting object rotation.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("ManipBox") + +# Rotate the object 45 degrees around Z axis +obj.Placement.Rotation = FreeCAD.Rotation(FreeCAD.Vector(0, 0, 1), 45) +doc.recompute() + +euler = obj.Placement.Rotation.toEuler() +_result_ = { + "rotation_z": euler[0], # Yaw + "valid": obj.Shape.isValid() +} +""", + ) + # Check rotation is approximately 45 degrees + assert abs(result["result"]["rotation_z"] - 45.0) < 0.1 + assert result["result"]["valid"] is True + + +class TestExportOperations: + """Tests for export functionality in headless mode.""" + + @pytest.fixture(autouse=True) + def setup_document( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str + ) -> None: + """Create a document with objects for export tests.""" + execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +if "ExportTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("ExportTestDoc") +doc = FreeCAD.newDocument("ExportTestDoc") + +# Create a simple box +box = Part.makeBox(10, 10, 10) +obj = doc.addObject("Part::Feature", "ExportBox") +obj.Shape = box +doc.recompute() + +_result_ = True +""", + ) + self.temp_dir = temp_dir + + def test_export_step( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str + ) -> None: + """Test exporting to STEP format.""" + step_path = Path(temp_dir) / "test_export.step" + + result = execute_code( + xmlrpc_proxy, + f""" +import FreeCAD + +doc = FreeCAD.ActiveDocument +obj = doc.getObject("ExportBox") +obj.Shape.exportStep({step_path!r}) + +import os +_result_ = {{"exported": os.path.exists({step_path!r})}} +""", + ) + assert result["result"]["exported"] is True + assert step_path.exists() + + def test_save_fcstd( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy, temp_dir: str + ) -> None: + """Test saving as FreeCAD native format.""" + fcstd_path = Path(temp_dir) / "test_save.FCStd" + + result = execute_code( + xmlrpc_proxy, + f""" +import FreeCAD + +doc = FreeCAD.ActiveDocument +doc.saveAs({fcstd_path!r}) + +import os +_result_ = {{ + "saved": os.path.exists({fcstd_path!r}), + "path": {fcstd_path!r} +}} +""", + ) + assert result["result"]["saved"] is True + assert fcstd_path.exists() + + +class TestGUIFeaturesInHeadless: + """Tests to verify GUI features fail gracefully in headless mode.""" + + def test_gui_features_not_available( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test that GUI-only features return appropriate errors.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD + +gui_available = FreeCAD.GuiUp + +if not gui_available: + _result_ = { + "success": False, + "error": "GUI not available in headless mode", + "gui_up": False + } +else: + _result_ = { + "success": True, + "gui_up": True + } +""", + ) + # In headless mode, GUI should not be available + assert result["result"]["gui_up"] is False + + +class TestComplexWorkflow: + """Tests for complex multi-step workflows in headless mode.""" + + def test_parametric_modeling_workflow( + self, xmlrpc_proxy: xmlrpc.client.ServerProxy + ) -> None: + """Test a complete parametric modeling workflow.""" + result = execute_code( + xmlrpc_proxy, + """ +import FreeCAD +import Part + +# Create document +if "WorkflowTestDoc" in FreeCAD.listDocuments(): + FreeCAD.closeDocument("WorkflowTestDoc") +doc = FreeCAD.newDocument("WorkflowTestDoc") + +# Step 1: Create base box +base_box = Part.makeBox(50, 50, 10) +base_obj = doc.addObject("Part::Feature", "Base") +base_obj.Shape = base_box + +# Step 2: Create cylinder to cut +cylinder = Part.makeCylinder(5, 20, FreeCAD.Vector(25, 25, -5)) + +# Step 3: Cut hole in base +result_shape = base_box.cut(cylinder) +final_obj = doc.addObject("Part::Feature", "BaseWithHole") +final_obj.Shape = result_shape + +# Step 4: Add fillet (rounded edges) - using Part API +# Select edges and apply fillet +try: + filleted = final_obj.Shape.makeFillet(2, final_obj.Shape.Edges[:4]) + fillet_obj = doc.addObject("Part::Feature", "Filleted") + fillet_obj.Shape = filleted + fillet_success = True +except Exception: + fillet_success = False + +doc.recompute() + +_result_ = { + "objects": [obj.Name for obj in doc.Objects], + "final_volume": final_obj.Shape.Volume, + "final_valid": final_obj.Shape.isValid(), + "fillet_success": fillet_success +} +""", + ) + assert "Base" in result["result"]["objects"] + assert "BaseWithHole" in result["result"]["objects"] + assert result["result"]["final_valid"] is True + # Volume should be base - cylinder hole + assert result["result"]["final_volume"] > 0 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..74a20fd --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for FreeCAD MCP Server.""" diff --git a/tests/unit/test_bridge_base.py b/tests/unit/test_bridge_base.py new file mode 100644 index 0000000..af1c951 --- /dev/null +++ b/tests/unit/test_bridge_base.py @@ -0,0 +1,127 @@ +"""Tests for bridge base classes.""" + +import pytest + +from freecad_mcp.bridge.base import ( + DocumentInfo, + ExecutionResult, + FreecadBridge, + ObjectInfo, +) + + +class TestExecutionResult: + """Tests for ExecutionResult dataclass.""" + + def test_successful_result(self): + """Successful execution result should have correct attributes.""" + result = ExecutionResult( + success=True, + result={"value": 42}, + stdout="output", + stderr="", + execution_time_ms=10.5, + ) + + assert result.success is True + assert result.result == {"value": 42} + assert result.stdout == "output" + assert result.stderr == "" + assert result.execution_time_ms == 10.5 + assert result.error_type is None + assert result.error_traceback is None + + def test_failed_result(self): + """Failed execution result should include error details.""" + result = ExecutionResult( + success=False, + result=None, + stdout="", + stderr="error occurred", + execution_time_ms=5.0, + error_type="ValueError", + error_traceback="Traceback...", + ) + + assert result.success is False + assert result.result is None + assert result.error_type == "ValueError" + assert result.error_traceback == "Traceback..." + + +class TestDocumentInfo: + """Tests for DocumentInfo dataclass.""" + + def test_document_with_all_fields(self): + """DocumentInfo should store all fields correctly.""" + doc = DocumentInfo( + name="TestDoc", + path="/path/to/doc.FCStd", + objects=["Box", "Cylinder"], + is_modified=True, + label="Test Document", + ) + + assert doc.name == "TestDoc" + assert doc.path == "/path/to/doc.FCStd" + assert doc.objects == ["Box", "Cylinder"] + assert doc.is_modified is True + assert doc.label == "Test Document" + + def test_document_with_defaults(self): + """DocumentInfo should use sensible defaults.""" + doc = DocumentInfo(name="Doc", path=None) + + assert doc.name == "Doc" + assert doc.path is None + assert doc.objects == [] + assert doc.is_modified is False + assert doc.label == "Doc" # Label defaults to name + + +class TestObjectInfo: + """Tests for ObjectInfo dataclass.""" + + def test_object_with_shape(self): + """ObjectInfo should store shape information correctly.""" + obj = ObjectInfo( + name="Box", + label="My Box", + type_id="Part::Box", + properties={"Length": 10.0}, + shape_info={"type": "Solid", "volume": 1000.0}, + children=["Child1"], + ) + + assert obj.name == "Box" + assert obj.label == "My Box" + assert obj.type_id == "Part::Box" + assert obj.properties == {"Length": 10.0} + assert obj.shape_info == {"type": "Solid", "volume": 1000.0} + assert obj.children == ["Child1"] + + def test_object_with_defaults(self): + """ObjectInfo should use sensible defaults.""" + obj = ObjectInfo(name="Obj", label="Obj", type_id="Part::Feature") + + assert obj.properties == {} + assert obj.shape_info is None + assert obj.children == [] + + +class TestFreecadBridgeInterface: + """Tests for FreecadBridge abstract interface.""" + + def test_cannot_instantiate_abstract_class(self): + """FreecadBridge should not be instantiable directly.""" + with pytest.raises(TypeError): + FreecadBridge() # type: ignore[abstract] + + def test_subclass_must_implement_methods(self): + """Subclass must implement all abstract methods.""" + + class IncompleteBridge(FreecadBridge): + pass + + with pytest.raises(TypeError): + IncompleteBridge() # type: ignore[abstract] diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..a890afc --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,84 @@ +"""Tests for configuration module.""" + +import os +from unittest import mock + +import pytest + +from freecad_mcp.config import FreecadMode, ServerConfig, TransportType, get_config + + +class TestServerConfig: + """Tests for ServerConfig class.""" + + def test_default_values(self): + """Default configuration should use sensible defaults.""" + with mock.patch.dict(os.environ, {}, clear=True): + config = ServerConfig() + + assert config.mode == FreecadMode.EMBEDDED + assert config.socket_host == "localhost" + assert config.socket_port == 9876 + assert config.timeout_ms == 30000 + assert config.max_output_size == 1_000_000 + assert config.transport == TransportType.STDIO + assert config.http_port == 8000 + assert config.log_level == "INFO" + assert config.enable_sandbox is True + + def test_socket_mode_from_env(self): + """Configuration should read mode from environment.""" + with mock.patch.dict(os.environ, {"FREECAD_MODE": "socket"}): + config = ServerConfig() + + assert config.mode == FreecadMode.SOCKET + + def test_custom_port_from_env(self): + """Configuration should read port from environment.""" + with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "12345"}): + config = ServerConfig() + + assert config.socket_port == 12345 + + def test_http_transport_from_env(self): + """Configuration should read transport from environment.""" + with mock.patch.dict(os.environ, {"FREECAD_TRANSPORT": "http"}): + config = ServerConfig() + + assert config.transport == TransportType.HTTP + + def test_invalid_port_raises_error(self): + """Invalid port should raise validation error.""" + with mock.patch.dict(os.environ, {"FREECAD_SOCKET_PORT": "99999"}): + with pytest.raises(Exception): # Pydantic validation error + ServerConfig() + + def test_get_config_returns_instance(self): + """get_config should return a ServerConfig instance.""" + config = get_config() + + assert isinstance(config, ServerConfig) + + +class TestFreecadMode: + """Tests for FreecadMode enum.""" + + def test_embedded_value(self): + """EMBEDDED should have correct string value.""" + assert FreecadMode.EMBEDDED.value == "embedded" + + def test_socket_value(self): + """SOCKET should have correct string value.""" + assert FreecadMode.SOCKET.value == "socket" + + +class TestTransportType: + """Tests for TransportType enum.""" + + def test_stdio_value(self): + """STDIO should have correct string value.""" + assert TransportType.STDIO.value == "stdio" + + def test_http_value(self): + """HTTP should have correct string value.""" + assert TransportType.HTTP.value == "http" diff --git a/tests/unit/test_embedded_bridge.py b/tests/unit/test_embedded_bridge.py new file mode 100644 index 0000000..77793f6 --- /dev/null +++ b/tests/unit/test_embedded_bridge.py @@ -0,0 +1,153 @@ +"""Tests for embedded bridge implementation.""" + +from unittest import mock + +import pytest + +from freecad_mcp.bridge.embedded import EmbeddedBridge + + +class TestEmbeddedBridge: + """Tests for EmbeddedBridge class.""" + + def test_initialization(self): + """Bridge should initialize with correct defaults.""" + bridge = EmbeddedBridge() + + assert bridge._freecad_path is None + assert bridge._fc_module is None + assert bridge._connected is False + + def test_initialization_with_path(self): + """Bridge should accept custom FreeCAD path.""" + bridge = EmbeddedBridge(freecad_path="/custom/path") + + assert bridge._freecad_path == "/custom/path" + + @pytest.mark.asyncio + async def test_is_connected_before_connect(self): + """is_connected should return False before connect.""" + bridge = EmbeddedBridge() + + assert await bridge.is_connected() is False + + @pytest.mark.asyncio + async def test_execute_python_when_not_connected(self): + """execute_python should return error when not connected.""" + bridge = EmbeddedBridge() + + result = await bridge.execute_python("x = 1") + + assert result.success is False + assert result.error_type == "ConnectionError" + assert "not connected" in result.stderr + + @pytest.mark.asyncio + async def test_disconnect_when_not_connected(self): + """disconnect should handle not being connected.""" + bridge = EmbeddedBridge() + + # Should not raise + await bridge.disconnect() + + assert bridge._connected is False + + +class TestEmbeddedBridgeCodeExecution: + """Tests for code execution in embedded bridge.""" + + @pytest.fixture + def mock_freecad(self): + """Create a mock FreeCAD module.""" + mock_fc = mock.MagicMock() + mock_fc.Version.return_value = ["0", "21", "2", "2024-01-01"] + mock_fc.listDocuments.return_value = {} + mock_fc.ActiveDocument = None + return mock_fc + + @pytest.mark.asyncio + async def test_execute_simple_code(self, mock_freecad): + """execute_python should execute simple Python code.""" + bridge = EmbeddedBridge() + bridge._fc_module = mock_freecad + bridge._connected = True + + result = await bridge.execute_python("_result_ = 1 + 1") + + assert result.success is True + assert result.result == 2 + + @pytest.mark.asyncio + async def test_execute_code_with_print(self, mock_freecad): + """execute_python should capture stdout.""" + bridge = EmbeddedBridge() + bridge._fc_module = mock_freecad + bridge._connected = True + + result = await bridge.execute_python("print('hello')") + + assert result.success is True + assert "hello" in result.stdout + + @pytest.mark.asyncio + async def test_execute_code_with_error(self, mock_freecad): + """execute_python should capture exceptions.""" + bridge = EmbeddedBridge() + bridge._fc_module = mock_freecad + bridge._connected = True + + result = await bridge.execute_python("raise ValueError('test error')") + + assert result.success is False + assert result.error_type == "ValueError" + assert result.error_traceback is not None + assert "test error" in result.error_traceback + + @pytest.mark.asyncio + async def test_execute_code_with_syntax_error(self, mock_freecad): + """execute_python should handle syntax errors.""" + bridge = EmbeddedBridge() + bridge._fc_module = mock_freecad + bridge._connected = True + + result = await bridge.execute_python("def bad syntax") + + assert result.success is False + assert result.error_type == "SyntaxError" + + +class TestEmbeddedBridgeDocuments: + """Tests for document handling in embedded bridge.""" + + @pytest.fixture + def mock_freecad_with_doc(self): + """Create mock FreeCAD with a document.""" + mock_doc = mock.MagicMock() + mock_doc.Name = "TestDoc" + mock_doc.Label = "Test Document" + mock_doc.FileName = "/tmp/test.FCStd" + mock_doc.Modified = False + mock_doc.Objects = [] + + mock_fc = mock.MagicMock() + mock_fc.listDocuments.return_value = {"TestDoc": mock_doc} + mock_fc.ActiveDocument = mock_doc + mock_fc.getDocument.return_value = mock_doc + + return mock_fc + + @pytest.mark.asyncio + async def test_get_documents_empty(self): + """get_documents should return empty list when no docs.""" + mock_fc = mock.MagicMock() + mock_fc.listDocuments.return_value = {} + + bridge = EmbeddedBridge() + bridge._fc_module = mock_fc + bridge._connected = True + + # Mock the execute_python to return empty list + result = await bridge.get_documents() + + # Since we're not mocking exec properly, we expect empty + assert isinstance(result, list) diff --git a/tests/unit/test_socket_bridge.py b/tests/unit/test_socket_bridge.py new file mode 100644 index 0000000..7925561 --- /dev/null +++ b/tests/unit/test_socket_bridge.py @@ -0,0 +1,142 @@ +"""Tests for socket bridge implementation.""" + +import asyncio +import json +from unittest import mock + +import pytest + +from freecad_mcp.bridge.socket import SocketBridge + + +class TestSocketBridge: + """Tests for SocketBridge class.""" + + def test_initialization_defaults(self): + """Bridge should initialize with correct defaults.""" + bridge = SocketBridge() + + assert bridge._host == "localhost" + assert bridge._port == 9876 + assert bridge._reader is None + assert bridge._writer is None + + def test_initialization_custom(self): + """Bridge should accept custom host and port.""" + bridge = SocketBridge(host="192.168.1.1", port=12345) + + assert bridge._host == "192.168.1.1" + assert bridge._port == 12345 + + @pytest.mark.asyncio + async def test_is_connected_before_connect(self): + """is_connected should return False before connect.""" + bridge = SocketBridge() + + assert await bridge.is_connected() is False + + @pytest.mark.asyncio + async def test_connect_failure(self): + """connect should raise ConnectionError on failure.""" + bridge = SocketBridge(host="invalid-host-12345", port=99999) + + with pytest.raises(ConnectionError): + await bridge.connect() + + @pytest.mark.asyncio + async def test_disconnect_when_not_connected(self): + """disconnect should handle not being connected.""" + bridge = SocketBridge() + + # Should not raise + await bridge.disconnect() + + @pytest.mark.asyncio + async def test_execute_python_when_not_connected(self): + """execute_python should return error when not connected.""" + bridge = SocketBridge() + + result = await bridge.execute_python("x = 1") + + assert result.success is False + assert result.error_type == "ConnectionError" + + +class TestSocketBridgeCommunication: + """Tests for socket communication.""" + + @pytest.fixture + def mock_streams(self): + """Create mock reader and writer streams.""" + reader = mock.AsyncMock(spec=asyncio.StreamReader) + writer = mock.MagicMock(spec=asyncio.StreamWriter) + writer.is_closing.return_value = False + writer.drain = mock.AsyncMock() + writer.close = mock.MagicMock() + writer.wait_closed = mock.AsyncMock() + return reader, writer + + @pytest.mark.asyncio + async def test_send_request_format(self, mock_streams): + """Requests should be formatted correctly when connected.""" + reader, writer = mock_streams + bridge = SocketBridge() + bridge._reader = reader + bridge._writer = writer + bridge._connected = True + + # Setup response + response = {"jsonrpc": "2.0", "id": "test-1", "result": {"success": True}} + reader.readline.return_value = json.dumps(response).encode() + b"\n" + + # Our bridge sends and receives in one call + # Just verify we can set up the bridge state correctly + assert bridge._reader is not None + assert bridge._writer is not None + assert bridge._connected is True + + +class TestSocketBridgeDocuments: + """Tests for document handling via socket.""" + + @pytest.mark.asyncio + async def test_get_documents_when_not_connected(self): + """get_documents should return empty list when not connected.""" + bridge = SocketBridge() + + # This will fail to send request, should return empty + docs = await bridge.get_documents() + + assert docs == [] + + @pytest.mark.asyncio + async def test_get_active_document_when_not_connected(self): + """get_active_document should return None when not connected.""" + bridge = SocketBridge() + + doc = await bridge.get_active_document() + + assert doc is None + + @pytest.mark.asyncio + async def test_is_gui_available_when_not_connected(self): + """is_gui_available should return False when not connected.""" + bridge = SocketBridge() + + result = await bridge.is_gui_available() + + assert result is False + + +class TestSocketBridgeVersionInfo: + """Tests for version info handling.""" + + @pytest.mark.asyncio + async def test_get_version_when_not_connected(self): + """get_freecad_version should return unknown when not connected.""" + bridge = SocketBridge() + + version = await bridge.get_freecad_version() + + assert version["version"] == "unknown" + assert version["gui_available"] is False