diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 4072429..bb7c8b3 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -69,10 +69,12 @@ reviews: This code runs inside FreeCAD's Python environment as a workbench addon. It cannot import packages from the project's virtualenv (mcp, pydantic). Watch for accidental imports of project dependencies. - - path: "macros/**/*.FCMacro" + - path: ".github/actions/**/*.yaml" instructions: > - FreeCAD macro files. These are standalone Python scripts. - They run in FreeCAD's environment with access to FreeCAD, Part, etc. + Custom GitHub Actions (composite actions). Check for: + - Proper input/output definitions + - Shell script correctness + - Cross-platform compatibility (x86_64/aarch64) - path: "tests/**/*.py" instructions: > Test files. Ensure good test coverage and clear assertions. @@ -105,14 +107,12 @@ reviews: - The combination of '>=' in pyproject.toml + uv.lock is the modern standard - path: "README.md" instructions: > - INTENTIONAL NAMING: The repo is "freecad-robust-mcp-and-more" but Docker - image and PyPI package are "freecad-robust-mcp". This is documented and - intentional - do NOT flag as a naming mismatch or inconsistency. + Main documentation for the FreeCAD Robust MCP Server addon. + The PyPI package name is "freecad-robust-mcp". - path: "Dockerfile" instructions: > - INTENTIONAL NAMING: Image name "freecad-robust-mcp" differs from repo name - "freecad-robust-mcp-and-more". This is intentional and documented in README. - Do NOT flag as a naming mismatch. + Docker image for running FreeCAD MCP Server in containers. + Image name is "freecad-robust-mcp". - path: ".mise.toml" instructions: > Tool version management via mise. All versions use fuzzy matching: diff --git a/.dockerignore b/.dockerignore index d73f104..7d2c83f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -78,9 +78,6 @@ site/ .DS_Store Thumbs.db -# Macros (not needed in container) -macros/ - # Tests (not needed in runtime container) tests/ # But allow CI test scripts for GUI testing container diff --git a/.github/workflows/macro-cut-magnets-release.yaml b/.github/workflows/macro-cut-magnets-release.yaml deleted file mode 100644 index d3d4368..0000000 --- a/.github/workflows/macro-cut-magnets-release.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Macro Cut Object for Magnets Release - -on: - push: - tags: - - 'macro-cut-object-for-magnets-v*' - workflow_dispatch: - workflow_call: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - release: - uses: ./.github/workflows/macro-release-reusable.yaml - with: - tag_prefix: 'macro-cut-object-for-magnets-v' - macro_dir: 'Cut_Object_for_Magnets' - macro_file: 'CutObjectForMagnets.FCMacro' - macro_name: 'Cut Object for Magnets' - archive_name: 'CutObjectForMagnets' - readme_file: 'README-CutObjectForMagnets.md' - wiki_url: 'https://wiki.freecad.org/Macro_Cut_Object_for_Magnets' - description: | - A FreeCAD macro that cuts 3D objects along a plane and automatically adds aligned magnet holes with surface collision detection. Perfect for creating 3D printed parts that snap together with embedded magnets. - - **Features:** - - Cut along preset planes (XY, XZ, YZ) or model datum planes - - Automatic hole placement with even distribution - - Surface collision detection prevents holes from breaking through walls - - Configurable hole diameter, depth, and count - - Creates PartDesign::Body objects with parametric Hole features - permissions: - contents: write diff --git a/.github/workflows/macro-multi-export-release.yaml b/.github/workflows/macro-multi-export-release.yaml deleted file mode 100644 index 765343c..0000000 --- a/.github/workflows/macro-multi-export-release.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: Macro Multi Export Release - -on: - push: - tags: - - 'macro-multi-export-v*' - workflow_dispatch: - workflow_call: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - release: - uses: ./.github/workflows/macro-release-reusable.yaml - with: - tag_prefix: 'macro-multi-export-v' - macro_dir: 'Multi_Export' - macro_file: 'MultiExport.FCMacro' - macro_name: 'Multi Export' - archive_name: 'MultiExport' - readme_file: 'README-MultiExport.md' - wiki_url: 'https://wiki.freecad.org/Macro_Multi_Export' - description: | - A FreeCAD macro that exports selected bodies to multiple file formats simultaneously with a user-friendly dialog. - - **Features:** - - Export to STL, STEP, 3MF, OBJ, IGES, BREP, PLY, and AMF formats - - Batch export multiple objects at once - - Configurable mesh quality settings (tolerance and deflection) - - Real-time file preview before export - - Remember last-used export directory - permissions: - contents: write diff --git a/.github/workflows/macro-release-reusable.yaml b/.github/workflows/macro-release-reusable.yaml deleted file mode 100644 index 4bc888a..0000000 --- a/.github/workflows/macro-release-reusable.yaml +++ /dev/null @@ -1,312 +0,0 @@ -name: Macro Release (Reusable) - -# Reusable workflow for releasing individual FreeCAD macros -# Called by component-specific workflows with appropriate inputs - -on: - workflow_dispatch: - inputs: - tag_prefix: - description: 'Tag prefix to match (e.g., macro-cut-object-for-magnets-v)' - required: true - type: string - macro_dir: - description: 'Macro directory relative to macros/ (e.g., Cut_Object_for_Magnets)' - required: true - type: string - macro_file: - description: 'Main macro filename (e.g., CutObjectForMagnets.FCMacro)' - required: true - type: string - macro_name: - description: 'Human-readable macro name (e.g., Cut Object for Magnets)' - required: true - type: string - archive_name: - description: 'Archive base name (e.g., CutObjectForMagnets)' - required: true - type: string - readme_file: - description: 'README filename (e.g., README-CutObjectForMagnets.md)' - required: true - type: string - wiki_url: - description: 'FreeCAD Wiki URL for this macro' - required: false - type: string - default: '' - description: - description: 'Short description for release notes' - required: true - type: string - workflow_call: - inputs: - tag_prefix: - description: 'Tag prefix to match (e.g., macro-cut-object-for-magnets-v)' - required: true - type: string - macro_dir: - description: 'Macro directory relative to macros/ (e.g., Cut_Object_for_Magnets)' - required: true - type: string - macro_file: - description: 'Main macro filename (e.g., CutObjectForMagnets.FCMacro)' - required: true - type: string - macro_name: - description: 'Human-readable macro name (e.g., Cut Object for Magnets)' - required: true - type: string - archive_name: - description: 'Archive base name (e.g., CutObjectForMagnets)' - required: true - type: string - readme_file: - description: 'README filename (e.g., README-CutObjectForMagnets.md)' - required: true - type: string - wiki_url: - description: 'FreeCAD Wiki URL for this macro' - required: false - type: string - default: '' - description: - description: 'Short description for release notes' - required: true - type: string - -jobs: - validate-and-release: - name: Validate Tag and Create Release - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Validate semantic version tag - id: version - env: - TAG_PREFIX: ${{ inputs.tag_prefix }} - run: | - TAG="${GITHUB_REF#refs/tags/}" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - # Build regex pattern from tag prefix - PATTERN="^${TAG_PREFIX}([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)\$" - - if [[ ! "$TAG" =~ $PATTERN ]]; then - echo "ERROR: Tag '$TAG' does not match expected format (${TAG_PREFIX}X.Y.Z)" - exit 1 - fi - - VERSION="${BASH_REMATCH[1]}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - # Check if this is a prerelease - if [[ "$VERSION" =~ -[a-zA-Z0-9.]+ ]]; then - echo "is_prerelease=true" >> "$GITHUB_OUTPUT" - else - echo "is_prerelease=false" >> "$GITHUB_OUTPUT" - fi - - echo "Parsed version: $VERSION" - - - name: Verify macro version in files - env: - VERSION: ${{ steps.version.outputs.version }} - MACRO_DIR: ${{ inputs.macro_dir }} - MACRO_FILE: ${{ inputs.macro_file }} - MACRO_NAME: ${{ inputs.macro_name }} - run: | - MACRO_PATH="macros/${MACRO_DIR}/${MACRO_FILE}" - - echo "Verifying version in source files matches tag: $VERSION" - - # Check __Version__ in .FCMacro file - if [ -f "$MACRO_PATH" ]; then - MACRO_VERSION=$(grep -o '__Version__ = "[^"]*"' "$MACRO_PATH" | cut -d'"' -f2) - if [ "$MACRO_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in $MACRO_PATH" - echo " Expected: $VERSION" - echo " Found: $MACRO_VERSION" - echo "" - echo "The version in source files must be updated before tagging." - echo "Run the appropriate bump command first." - exit 1 - fi - echo "✓ $MACRO_PATH: $MACRO_VERSION" - else - echo "ERROR: Macro file not found: $MACRO_PATH" - exit 1 - fi - - # Check wiki-source.txt - WIKI_FILE="macros/${MACRO_DIR}/wiki-source.txt" - if [ -f "$WIKI_FILE" ]; then - WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n') - if [ "$WIKI_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in $WIKI_FILE" - echo " Expected: $VERSION" - echo " Found: $WIKI_VERSION" - echo "" - echo "Run the appropriate bump command first." - exit 1 - fi - echo "✓ $WIKI_FILE: $WIKI_VERSION" - fi - - # Check package.xml - PKG_VERSION=$(awk -v name="$MACRO_NAME" ' - // { in_macro=1 } - /<\/macro>/ { in_macro=0; found_name=0 } - in_macro && index($0, name) > 0 { found_name=1 } - in_macro && found_name && // { - gsub(/.*/, ""); gsub(/<\/version>.*/, ""); print; exit - } - ' package.xml) - if [ "$PKG_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in package.xml ($MACRO_NAME section)" - echo " Expected: $VERSION" - echo " Found: $PKG_VERSION" - exit 1 - fi - echo "✓ package.xml ($MACRO_NAME): $PKG_VERSION" - - echo "" - echo "Version verification passed!" - - - name: Create macro archive - env: - VERSION: ${{ steps.version.outputs.version }} - MACRO_DIR: ${{ inputs.macro_dir }} - MACRO_FILE: ${{ inputs.macro_file }} - ARCHIVE_NAME: ${{ inputs.archive_name }} - README_FILE: ${{ inputs.readme_file }} - run: | - # Create staging directory - mkdir -p "staging/${ARCHIVE_NAME}-${VERSION}" - - # Copy macro files - cp "macros/${MACRO_DIR}/${MACRO_FILE}" "staging/${ARCHIVE_NAME}-${VERSION}/" - - # Copy icon if exists - for ext in svg png; do - ICON_FILE="macros/${MACRO_DIR}/${ARCHIVE_NAME}.${ext}" - if [ -f "$ICON_FILE" ]; then - cp "$ICON_FILE" "staging/${ARCHIVE_NAME}-${VERSION}/" - fi - done - - # Copy README - if [ -f "macros/${MACRO_DIR}/${README_FILE}" ]; then - cp "macros/${MACRO_DIR}/${README_FILE}" "staging/${ARCHIVE_NAME}-${VERSION}/README.md" - fi - - # Copy LICENSE - cp LICENSE "staging/${ARCHIVE_NAME}-${VERSION}/" - - # Create tar.gz archive - cd staging - tar -czvf "${ARCHIVE_NAME}-${VERSION}.tar.gz" "${ARCHIVE_NAME}-${VERSION}" - - # Create zip archive - zip -r "${ARCHIVE_NAME}-${VERSION}.zip" "${ARCHIVE_NAME}-${VERSION}" - - mv "${ARCHIVE_NAME}-${VERSION}.tar.gz" ../ - mv "${ARCHIVE_NAME}-${VERSION}.zip" ../ - cd .. - - echo "Created archives:" - ls -la "${ARCHIVE_NAME}-${VERSION}."* - - - name: Extract release notes section - id: changelog - env: - VERSION: ${{ steps.version.outputs.version }} - MACRO_NAME: ${{ inputs.macro_name }} - DESCRIPTION: ${{ inputs.description }} - MACRO_FILE: ${{ inputs.macro_file }} - MACRO_DIR: ${{ inputs.macro_dir }} - README_FILE: ${{ inputs.readme_file }} - WIKI_URL: ${{ inputs.wiki_url }} - run: | - RELEASE_NOTES="macros/${MACRO_DIR}/RELEASE_NOTES.md" - - # Extract section for this version from RELEASE_NOTES.md - # Format: ## Version X.Y.Z (date) - CHANGELOG_CONTENT=$(awk -v version="$VERSION" ' - BEGIN { found=0 } - /^## Version / { - if (found) exit - if (index($0, version) > 0) { found=1; next } - } - found { print } - ' "$RELEASE_NOTES" 2>/dev/null || echo "") - - # Build release body - cat > release_body.md << EOF - ## ${MACRO_NAME} Macro v${VERSION} - - ${DESCRIPTION} - - ### Installation - - Copy \`${MACRO_FILE}\` to your FreeCAD macro directory: - - - **macOS**: \`~/Library/Application Support/FreeCAD/Macro/\` - - **Linux**: \`~/.local/share/FreeCAD/Macro/\` - - **Windows**: \`%APPDATA%/FreeCAD/Macro/\` - - Optionally copy the icon file (\`.svg\` or \`.png\`) to the same location. - - ### Documentation - - - [Full README](https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/${MACRO_DIR}/${README_FILE}) - EOF - - if [ -n "$WIKI_URL" ]; then - echo "- [FreeCAD Wiki Page](${WIKI_URL})" >> release_body.md - fi - - if [ -n "$CHANGELOG_CONTENT" ]; then - { - echo "" - echo "### Changelog" - echo "" - echo "$CHANGELOG_CONTENT" - } >> release_body.md - fi - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: "${{ inputs.macro_name }} Macro v${{ steps.version.outputs.version }}" - tag_name: ${{ github.ref_name }} - prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }} - generate_release_notes: true - body_path: release_body.md - files: | - ${{ inputs.archive_name }}-${{ steps.version.outputs.version }}.tar.gz - ${{ inputs.archive_name }}-${{ steps.version.outputs.version }}.zip - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Generate summary - env: - VERSION: ${{ steps.version.outputs.version }} - MACRO_NAME: ${{ inputs.macro_name }} - ARCHIVE_NAME: ${{ inputs.archive_name }} - run: | - { - echo "## ${MACRO_NAME} Macro Release" - echo "" - echo "**Version:** ${VERSION}" - echo "" - echo "### Downloads" - echo "" - echo "- \`${ARCHIVE_NAME}-${VERSION}.tar.gz\`" - echo "- \`${ARCHIVE_NAME}-${VERSION}.zip\`" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/macro-test.yaml b/.github/workflows/macro-test.yaml deleted file mode 100644 index d9b80bf..0000000 --- a/.github/workflows/macro-test.yaml +++ /dev/null @@ -1,422 +0,0 @@ -name: Integration Tests - -on: - push: - branches: [main, master] - paths: - - "src/freecad_mcp/**/*.py" - - "macros/**/*.FCMacro" - - "macros/**/*.py" - - "addon/FreecadRobustMCPBridge/**/*.py" - - "tests/integration/**/*.py" - - ".github/workflows/macro-test.yaml" - - ".github/actions/setup-freecad/**" - pull_request: - branches: [main, master] - paths: - - "src/freecad_mcp/**/*.py" - - "macros/**/*.FCMacro" - - "macros/**/*.py" - - "addon/FreecadRobustMCPBridge/**/*.py" - - "tests/integration/**/*.py" - - ".github/workflows/macro-test.yaml" - - ".github/actions/setup-freecad/**" - workflow_dispatch: - workflow_call: - -# Cancel in-progress runs for the same branch -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - test-integration: - name: Integration Tests with FreeCAD - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install mise - uses: jdx/mise-action@v3 - - - name: Setup FreeCAD - uses: ./.github/actions/setup-freecad - - - name: Cache uv dependencies - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - cache-dependency-glob: "**/uv.lock" - - - 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: | - echo "Using FreeCAD: freecadcmd" - - # Start FreeCAD headless with MCP bridge in background - # Uses the workbench addon's blocking bridge script - # Use setsid to create a new process group for reliable cleanup - setsid freecadcmd addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_bridge.log 2>&1 & - 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" - echo "=== Bridge log ===" - cat /tmp/freecad_bridge.log || true - kill "$FREECAD_PID" 2>/dev/null || true - exit 1 - fi - sleep 1 - done - - # Log the instance ID for debugging (pytest tests verify bridge via conftest.py fixtures) - BRIDGE_INSTANCE_ID=$(grep -o 'FREECAD_MCP_BRIDGE_INSTANCE_ID=[^ ]*' /tmp/freecad_bridge.log | cut -d= -f2 | head -1) - if [ -n "$BRIDGE_INSTANCE_ID" ]; then - echo "Bridge Instance ID: $BRIDGE_INSTANCE_ID" - else - echo "Warning: Could not extract bridge instance ID from log" - cat /tmp/freecad_bridge.log || true - fi - - - name: Run CutObjectForMagnets macro tests - env: - FREECAD_MODE: xmlrpc - # Note: Tests use boolean holes (not PartDesign::Hole) for CI compatibility. - # PartDesign::Hole has a CADKernelError bug in some headless AppImage environments. - run: | - uv run pytest tests/integration/test_cut_object_for_magnets.py -v --tb=short - - - name: Run MultiExport macro tests - env: - FREECAD_MODE: xmlrpc - # Export functionality should work in headless mode - continue-on-error: true - run: | - uv run pytest tests/integration/test_multi_export.py -v --tb=short - - - name: Run headless mode integration tests - env: - FREECAD_MODE: xmlrpc - # Tests for headless FreeCAD operations (primitives, booleans, exports, etc.) - run: | - uv run pytest tests/integration/test_headless_mode.py -v --tb=short - - - name: Upload FreeCAD logs on failure - if: failure() || cancelled() - uses: actions/upload-artifact@v6 - with: - name: freecad-headless-logs - path: /tmp/freecad_bridge.log - retention-days: 7 - if-no-files-found: ignore - - - name: Stop FreeCAD - if: always() - run: | - if [ -n "$FREECAD_PID" ]; then - # Kill process group (handles child processes spawned by FreeCAD/AppImage) - kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true - fi - - test-gui: - name: GUI Tests with FreeCAD + Xvfb - runs-on: ubuntu-latest - timeout-minutes: 30 - # FreeCAD GUI requires a window manager to generate expose/configure events. - # Without a WM, the GUI binary hangs during Qt initialization. - # Solution: Xvfb + openbox window manager + xdotool for synthetic events. - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install mise - uses: jdx/mise-action@v3 - - # Note: apt cache removed - caching /var/cache/apt/archives causes permission - # issues and is largely negated by the immediate sudo apt-get update - - - name: Install Xvfb, openbox, and X11 dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - xvfb \ - openbox \ - xdotool \ - libxkbcommon-x11-0 \ - libxcb-icccm4 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-randr0 \ - libxcb-render-util0 \ - libxcb-xinerama0 \ - libxcb-xfixes0 \ - libxcb-shape0 \ - libxcb-cursor0 \ - x11-utils \ - libegl1 \ - libgl1-mesa-dri \ - libgl1 \ - mesa-utils \ - fontconfig \ - fonts-dejavu-core - # Rebuild font cache (required after installing fonts, especially from cache) - sudo fc-cache -f -v - - - name: Setup FreeCAD - uses: ./.github/actions/setup-freecad - - - name: Start Xvfb and openbox - run: | - echo "Starting Xvfb virtual display..." - # Use -nolisten tcp to prevent TCP listeners for security - Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & - XVFB_PID=$! - echo "XVFB_PID=$XVFB_PID" >> "$GITHUB_ENV" - echo "DISPLAY=:99" >> "$GITHUB_ENV" - sleep 2 - # Verify Xvfb is running - if ! ps -p $XVFB_PID > /dev/null; then - echo "ERROR: Xvfb failed to start" - exit 1 - fi - echo "Xvfb started on display :99 (PID: $XVFB_PID)" - - # Start openbox window manager (required for FreeCAD GUI) - # FreeCAD needs a WM to generate expose/configure events - echo "Starting openbox window manager..." - DISPLAY=:99 openbox & - OPENBOX_PID=$! - echo "OPENBOX_PID=$OPENBOX_PID" >> "$GITHUB_ENV" - sleep 1 - if ! ps -p $OPENBOX_PID > /dev/null; then - echo "WARNING: openbox may have failed to start" - else - echo "openbox started (PID: $OPENBOX_PID)" - fi - - - name: Verify X11 display - env: - DISPLAY: ":99" - run: | - echo "Checking X11 display..." - xdpyinfo | head -10 || echo "xdpyinfo not available" - - - name: Cache uv dependencies - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - cache-dependency-glob: "**/uv.lock" - - - name: Install dependencies - run: uv sync --all-extras - - - name: Verify fontconfig setup - run: | - echo "Checking fontconfig configuration..." - # Verify the system fontconfig config exists - if [ -f /etc/fonts/fonts.conf ]; then - echo "System fontconfig found at /etc/fonts/fonts.conf" - else - echo "ERROR: /etc/fonts/fonts.conf not found!" - ls -la /etc/fonts/ || echo "/etc/fonts/ directory doesn't exist" - fi - # Test fontconfig is working - echo "Testing fc-list..." - fc-list | head -5 || echo "fc-list failed" - echo "Fontconfig setup verified." - - - name: Start FreeCAD GUI with MCP bridge - env: - DISPLAY: ":99" - QT_QPA_PLATFORM: xcb - # Use software rendering for OpenGL (more reliable in CI) - LIBGL_ALWAYS_SOFTWARE: "1" - # Explicit fontconfig paths for AppImage compatibility - # The AppImage's bundled fontconfig may not find system config automatically - FONTCONFIG_FILE: /etc/fonts/fonts.conf - FONTCONFIG_PATH: /etc/fonts - # Set XDG_RUNTIME_DIR to avoid Qt warning - XDG_RUNTIME_DIR: /tmp/runtime-root - run: | - # Create XDG_RUNTIME_DIR - mkdir -p "$XDG_RUNTIME_DIR" - chmod 700 "$XDG_RUNTIME_DIR" - - echo "Starting FreeCAD GUI with MCP bridge under Xvfb + openbox..." - echo "DISPLAY=$DISPLAY" - echo "QT_QPA_PLATFORM=$QT_QPA_PLATFORM" - echo "LIBGL_ALWAYS_SOFTWARE=$LIBGL_ALWAYS_SOFTWARE" - echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" - - # Verify freecadcmd works (headless mode) - echo "=== Testing freecadcmd (headless) ===" - freecadcmd --version || echo "freecadcmd failed" - - # Export fontconfig vars so they're available to backgrounded process - export FONTCONFIG_FILE FONTCONFIG_PATH - - # Start FreeCAD GUI (not headless) with MCP bridge - # Uses blocking_bridge.py which blocks with run_forever() to keep process alive - # GUI features are available since we're using 'freecad' not 'freecadcmd' - # Use setsid to create a new process group for reliable cleanup - setsid freecad addon/FreecadRobustMCPBridge/freecad_mcp_bridge/blocking_bridge.py > /tmp/freecad_gui.log 2>&1 & - FREECAD_PID=$! - echo "FREECAD_PID=$FREECAD_PID" >> "$GITHUB_ENV" - - echo "Waiting for MCP bridge to start..." - # Send xdotool events to help FreeCAD GUI initialize - # FreeCAD needs mouse/keyboard events to process its event queue - for i in {1..90}; do - # Send synthetic events to help FreeCAD initialize - xdotool mousemove $((400 + i*3)) $((300 + i*3)) click 1 key Escape 2>/dev/null || true - - # Check if process is still running - if ! ps -p "$FREECAD_PID" > /dev/null 2>&1; then - echo "ERROR: FreeCAD process died (PID $FREECAD_PID)" - echo "=== FreeCAD GUI log ===" - cat /tmp/freecad_gui.log || echo "Log file is empty or missing" - exit 1 - fi - 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 90 ]; then - echo "ERROR: MCP bridge did not start within 90s" - echo "=== FreeCAD GUI log ===" - cat /tmp/freecad_gui.log || echo "Log file is empty or missing" - echo "=== Process status ===" - pgrep -a freecad || echo "No freecad processes found" - kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true - exit 1 - fi - # Show progress every 10 seconds - if [ $((i % 10)) -eq 0 ]; then - echo "Still waiting... (${i}s)" - # Show last few lines of log - tail -5 /tmp/freecad_gui.log 2>/dev/null || true - fi - sleep 1 - done - - # Verify GUI is available - sleep 2 - BRIDGE_INSTANCE_ID=$(grep -o 'FREECAD_MCP_BRIDGE_INSTANCE_ID=[^ ]*' /tmp/freecad_gui.log | cut -d= -f2 | head -1) - echo "Bridge Instance ID: $BRIDGE_INSTANCE_ID" - - # Check if GUI is up - if grep -q "GuiUp.*True\|GUI.*available" /tmp/freecad_gui.log 2>/dev/null; then - echo "FreeCAD GUI is available" - else - echo "Note: Could not confirm GUI status from log (may still work)" - fi - - - name: Run GUI mode integration tests - env: - FREECAD_MODE: xmlrpc - DISPLAY: ":99" - # Tests for GUI-only features (screenshots, visibility, colors, camera, etc.) - # GUI tests under Xvfb can be flaky; don't fail the workflow during stabilization - continue-on-error: true - run: | - uv run pytest tests/integration/test_gui_mode.py -v --tb=short - - - name: Upload FreeCAD logs on failure - if: failure() || cancelled() - uses: actions/upload-artifact@v6 - with: - name: freecad-gui-logs - path: /tmp/freecad_gui.log - retention-days: 7 - if-no-files-found: ignore - - - name: Stop FreeCAD, openbox, and Xvfb - if: always() - run: | - if [ -n "$FREECAD_PID" ]; then - # Kill process group (handles child processes spawned by FreeCAD/AppImage) - kill -- "-$FREECAD_PID" 2>/dev/null || kill "$FREECAD_PID" 2>/dev/null || true - fi - if [ -n "$OPENBOX_PID" ]; then - kill "$OPENBOX_PID" 2>/dev/null || true - fi - if [ -n "$XVFB_PID" ]; then - kill "$XVFB_PID" 2>/dev/null || true - fi - - lint-macros: - name: Lint Macro Files - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Install mise - uses: jdx/mise-action@v3 - - - name: Cache uv dependencies - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true - cache-dependency-glob: "**/uv.lock" - - - 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/test.yaml b/.github/workflows/test.yaml index 7c1a3ba..a01b0b9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -53,9 +53,3 @@ jobs: - name: Run type checking run: uv run mypy src/ - - # Note: FreeCAD integration tests are handled by the "Integration Tests" workflow - # (macro-test.yaml) which runs on every PR. That workflow provides: - # - Headless tests: test_headless_mode.py, test_cut_object_for_magnets.py, test_multi_export.py - # - GUI tests: test_gui_mode.py (uses Xvfb virtual display) - # See .github/workflows/macro-test.yaml for details. diff --git a/CLAUDE.md b/CLAUDE.md index 1e0daa1..e0c2a76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,9 +179,6 @@ just freecad::run-headless # Run FreeCAD headless with MCP bridge # Installation commands (for end users) just install::mcp-server # Install MCP server system-wide (via uv tool) just install::mcp-bridge-workbench # Install FreeCAD workbench addon -just install::macro-all # Install all macros -just install::macro-cut # Install CutObjectForMagnets macro -just install::macro-export # Install MultiExport macro just install::status # Check installation status # Quality commands @@ -195,7 +192,7 @@ just quality::scan # Run all secrets scanners # Testing commands just testing::unit # Run unit tests just testing::cov # Run tests with coverage -just testing::fast # Run tests without slow markers +just testing::quick # Run tests without slow markers just testing::integration # Run integration tests just testing::integration-freecad-auto # Integration tests with auto FreeCAD startup just testing::watch # Run tests in watch mode @@ -231,8 +228,6 @@ just coderabbit::review-fix # Review with auto-fix suggestions just release::status # Show unreleased changes across all components just release::tag-mcp-server 1.0.0 # Release MCP server (PyPI + Docker) just release::tag-workbench 1.0.0 # Release Robust MCP Bridge workbench -just release::tag-macro-magnets 1.0.0 # Release Cut Object for Magnets macro -just release::tag-macro-export 1.0.0 # Release Multi Export macro just release::list-tags # List all release tags just release::latest-versions # Show latest version of each component just release::delete-tag # Delete a release tag (local and remote) @@ -245,18 +240,18 @@ just all-with-integration # Run all checks and integration tests #### Just Module Structure -| Module | Description | Key Commands | -| --------------- | ------------------------------------- | --------------------------------------------------- | -| `mcp` | MCP server commands | `run`, `run-debug`, `run-http` | -| `freecad` | FreeCAD running commands | `run-gui`, `run-headless`, `run-gui-custom` | -| `install` | User installation commands | `mcp-server`, `mcp-bridge-workbench`, `macro-all` | -| `quality` | Code quality and linting | `check`, `lint`, `format`, `scan` | -| `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` | -| `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` | -| `documentation` | Documentation building and deployment | `build`, `serve`, `serve-versioned`, `list-versions`| -| `dev` | Development utilities | `install-deps`, `update-deps`, `clean` | -| `release` | Release and tagging | `status`, `tag-mcp-server`, `delete-tag` | -| `coderabbit` | AI code reviews (local) | `install`, `login`, `review`, `review-fix` | +| Module | Description | Key Commands | +| --------------- | ------------------------------------- | ---------------------------------------------------- | +| `mcp` | MCP server commands | `run`, `run-debug`, `run-http` | +| `freecad` | FreeCAD running commands | `run-gui`, `run-headless`, `run-gui-custom` | +| `install` | User installation commands | `mcp-server`, `mcp-bridge-workbench`, `status` | +| `quality` | Code quality and linting | `check`, `lint`, `format`, `scan` | +| `testing` | Test execution | `unit`, `cov`, `integration-freecad-auto`, `watch` | +| `docker` | Docker build and run commands | `build`, `build-multi`, `run`, `clean-all` | +| `documentation` | Documentation building and deployment | `build`, `serve`, `serve-versioned`, `list-versions` | +| `dev` | Development utilities | `install-deps`, `update-deps`, `clean` | +| `release` | Release and tagging | `status`, `tag-mcp-server`, `delete-tag` | +| `coderabbit` | AI code reviews (local) | `install`, `login`, `review`, `review-fix` | Module files are located in the `just/` directory. @@ -521,6 +516,26 @@ just documentation::deploy-latest 1.0.0 # Deploy version and set as latest **Note**: Local `deploy-*` commands modify the `gh-pages` branch locally. The GitHub Actions workflow handles actual deployment to GitHub Pages. +**Initial GitHub Pages Setup**: + +This repo uses **mike** for versioned documentation, which requires a `gh-pages` branch: + +1. **Create the gh-pages branch** (if it doesn't exist): + + ```bash + git checkout --orphan gh-pages + git reset --hard + git commit --allow-empty -m "Initialize gh-pages branch" + git push origin gh-pages + git checkout main + ``` + +2. **Configure GitHub Pages** in repo Settings → Pages: + - Source: **Deploy from a branch** + - Branch: `gh-pages` / `/ (root)` + +3. **First deployment**: Push to `main` or manually trigger the `docs.yaml` workflow + --- ## Testing Requirements @@ -587,7 +602,7 @@ class TestCalculateTotal: ```bash just testing::unit # Run unit tests just testing::cov # Run tests with coverage report -just testing::fast # Run tests without slow markers +just testing::quick # Run tests without slow markers just testing::all # Run all tests including integration uv run pytest tests/unit/ # Run specific test directory uv run pytest -k "test_name" # Run specific test by name @@ -737,10 +752,7 @@ project-root/ │ ├── workflows/ # GitHub Actions workflows │ │ ├── codeql.yaml # Security analysis │ │ ├── docker.yaml # Docker build (CI) -│ │ ├── macro-cut-magnets-release.yaml # Macro release -│ │ ├── macro-multi-export-release.yaml # Macro release -│ │ ├── macro-release-reusable.yaml # Shared macro release logic -│ │ ├── macro-test.yaml # Macro testing +│ │ ├── docs.yaml # Documentation deployment │ │ ├── mcp-server-release.yaml # MCP server → PyPI/Docker │ │ ├── mcp-workbench-release.yaml # Workbench → GitHub Release │ │ ├── pre-commit.yaml # Pre-commit checks @@ -757,7 +769,6 @@ project-root/ │ ├── development/ # Developer guides │ ├── getting-started/ # Installation, quickstart │ ├── guide/ # User guides -│ ├── macros/ # Macro documentation │ ├── reference/ # API reference │ ├── variables.yaml # MkDocs macro variables │ └── index.md # Documentation home @@ -772,13 +783,6 @@ project-root/ │ ├── quality.just # Code quality commands │ ├── release.just # Release/tagging commands │ └── testing.just # Test commands -├── macros/ # FreeCAD macro source -│ ├── Cut_Object_for_Magnets/ -│ │ ├── CutObjectForMagnets.FCMacro -│ │ └── README-CutObjectForMagnets.md -│ └── Multi_Export/ -│ ├── MultiExport.FCMacro -│ └── README-MultiExport.md ├── src/ │ └── freecad_mcp/ # Main MCP server package │ ├── bridge/ # FreeCAD connection bridges @@ -934,7 +938,7 @@ EOF 4. **Variable expansion**: `${VAR}` works inside heredocs for bash variables 5. **Use `\\n` for newlines**: In heredoc strings that need literal `\n`, use `\\n` -See the recipes in `just/freecad.just` (e.g., `run-gui`, `run-headless`, `install-cut-macro`) for working examples. +See the recipes in `just/freecad.just` (e.g., `run-gui`, `run-headless`) for working examples. --- @@ -1319,24 +1323,20 @@ This project uses component-specific release workflows along with CI/CD pipeline ### CI Workflows -| Workflow | Trigger | Purpose | -| ------------------ | ------------------------------ | --------------------------------------------------------- | -| `test.yaml` | Push, PR | Runs unit tests and integration tests on Ubuntu and macOS | -| `pre-commit.yaml` | Push, PR | Runs all pre-commit hooks for code quality | -| `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works | -| `macro-test.yaml` | Push, PR | Tests FreeCAD macros in headless Docker environment | -| `codeql.yaml` | Push, PR, scheduled | GitHub CodeQL security analysis | -| `docs.yaml` | Push to main, MCP server tags | Deploys versioned documentation to GitHub Pages | +| Workflow | Trigger | Purpose | +| ----------------- | ----------------------------- | --------------------------------------------------------- | +| `test.yaml` | Push, PR | Runs unit tests and integration tests on Ubuntu and macOS | +| `pre-commit.yaml` | Push, PR | Runs all pre-commit hooks for code quality | +| `docker.yaml` | Push, PR | Builds Docker image to verify Dockerfile works | +| `codeql.yaml` | Push, PR, scheduled | GitHub CodeQL security analysis | +| `docs.yaml` | Push to main, MCP server tags | Deploys versioned documentation to GitHub Pages | ### Release Workflows -| Workflow | Trigger | Purpose | -| --------------------------------- | ---------------------------------------- | ------------------------------------------------------ | -| `mcp-server-release.yaml` | Tag: `robust-mcp-server-v*` | Builds and publishes MCP server to PyPI and Docker Hub | -| `mcp-workbench-release.yaml` | Tag: `robust-mcp-workbench-v*` | Creates GitHub Release with workbench addon archive | -| `macro-cut-magnets-release.yaml` | Tag: `macro-cut-object-for-magnets-v*` | Creates GitHub Release with macro archive | -| `macro-multi-export-release.yaml` | Tag: `macro-multi-export-v*` | Creates GitHub Release with macro archive | -| `macro-release-reusable.yaml` | Called by macro release workflows | Shared logic for macro releases (DRY) | +| Workflow | Trigger | Purpose | +| ---------------------------- | ------------------------------ | ------------------------------------------------------ | +| `mcp-server-release.yaml` | Tag: `robust-mcp-server-v*` | Builds and publishes MCP server to PyPI and Docker Hub | +| `mcp-workbench-release.yaml` | Tag: `robust-mcp-workbench-v*` | Creates GitHub Release with workbench addon archive | ### Release Workflow Features @@ -1365,12 +1365,10 @@ This project uses component-specific release workflows along with CI/CD pipeline This project uses **component-specific versioning**. Each component has its own git tag and release workflow: -| Component | Tag Format | Releases To | -| ---------------------------- | ------------------------------------- | ------------------------------------------ | -| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release | -| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) | -| Cut Object for Magnets Macro | `macro-cut-object-for-magnets-vX.Y.Z` | GitHub Release (archive) | -| Multi Export Macro | `macro-multi-export-vX.Y.Z` | GitHub Release (archive) | +| Component | Tag Format | Releases To | +| ----------------- | ----------------------------- | ------------------------------------------ | +| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release | +| Robust MCP Bridge | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) | *Stable releases (`X.Y.Z`) publish to PyPI; non-stable releases (alpha, beta, rc) publish to TestPyPI only. @@ -1378,12 +1376,10 @@ This project uses **component-specific versioning**. Each component has its own Each component has its own `RELEASE_NOTES.md` file. Release workflows automatically extract the relevant section for GitHub Releases. -| Component | Release Notes File | -| ---------------------------- | ----------------------------------------------------- | -| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | -| Robust MCP Bridge Workbench | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | -| Cut Object for Magnets Macro | `macros/Cut_Object_for_Magnets/RELEASE_NOTES.md` | -| Multi Export Macro | `macros/Multi_Export/RELEASE_NOTES.md` | +| Component | Release Notes File | +| ----------------- | ----------------------------------------------- | +| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | +| Robust MCP Bridge | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | **Before releasing a component:** @@ -1392,8 +1388,6 @@ Each component has its own `RELEASE_NOTES.md` file. Release workflows automatica ```bash just release::draft-notes mcp-server just release::draft-notes workbench - just release::draft-notes macro-magnets - just release::draft-notes macro-export ``` 2. **Edit the component's RELEASE_NOTES.md** file, adding a new version section at the top: @@ -1438,12 +1432,10 @@ just release::draft-notes mcp-server # See "Release Notes Management" above for format ``` -**Step 3: Bump versions** (for workbench/macros only - MCP server auto-bumps from tag): +**Step 3: Bump versions** (for workbench only - MCP server auto-bumps from tag): ```bash just release::bump-workbench 1.0.0 -just release::bump-macro-magnets 1.0.0 -just release::bump-macro-export 1.0.0 ``` **Step 4: Commit and push** your RELEASE_NOTES.md and version bump changes. @@ -1456,10 +1448,6 @@ just release::tag-mcp-server 1.0.0 # Release the Robust MCP Bridge workbench just release::tag-workbench 1.0.0 - -# Release macros -just release::tag-macro-magnets 1.0.0 -just release::tag-macro-export 1.0.0 ``` **Utility commands:** @@ -1508,26 +1496,21 @@ All versions follow SemVer 2.0: 4. Creates archive (tar.gz + zip) 5. Creates GitHub release with archives -### Package.xml Per-Component Versioning +### Package.xml Versioning -The `package.xml` file uses per-content versioning as supported by FreeCAD: +The `package.xml` file contains metadata for the FreeCAD addon: ```xml - MCP Bridge + Robust MCP Bridge 1.0.0 ... - - Multi Export - 0.8.0 - ... - ``` -Each component can have a different version, and the release workflows automatically update these when a component is released. +The release workflow automatically updates the version when the workbench is released. --- @@ -1543,12 +1526,10 @@ Each component has its own `RELEASE_NOTES.md` file that is updated before releas **Release notes files:** -| Component | File | -| ---------------------------- | ------------------------------------------------ | -| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | -| Robust MCP Bridge Workbench | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | -| Cut Object for Magnets Macro | `macros/Cut_Object_for_Magnets/RELEASE_NOTES.md` | -| Multi Export Macro | `macros/Multi_Export/RELEASE_NOTES.md` | +| Component | File | +| ----------------- | ----------------------------------------------- | +| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | +| Robust MCP Bridge | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | --- diff --git a/README.md b/README.md index 77f9708..3262be8 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,23 @@ -# FreeCAD Robust MCP Suite +# FreeCAD Robust MCP Server -[![CI Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/test.yaml) -[![Integration Tests](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/macro-test.yaml) -[![Docker Build](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/docker.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/docker.yaml) -[![Pre-commit](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/pre-commit.yaml) -[![CodeQL](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/codeql.yaml/badge.svg)](https://github.com/spkane/freecad-robust-mcp-and-more/actions/workflows/codeql.yaml) +[![CI Tests](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/test.yaml/badge.svg)](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/test.yaml) +[![Docker Build](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/docker.yaml/badge.svg)](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/docker.yaml) +[![Pre-commit](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/pre-commit.yaml/badge.svg)](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/pre-commit.yaml) +[![CodeQL](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/codeql.yaml/badge.svg)](https://github.com/spkane/freecad-addon-robust-mcp-server/actions/workflows/codeql.yaml) [![PyPI Version](https://img.shields.io/pypi/v/freecad-robust-mcp)](https://pypi.org/project/freecad-robust-mcp/) [![Python Versions](https://img.shields.io/pypi/pyversions/freecad-robust-mcp)](https://pypi.org/project/freecad-robust-mcp/) [![Docker Image Version](https://img.shields.io/docker/v/spkane/freecad-robust-mcp?sort=semver&label=docker)](https://hub.docker.com/r/spkane/freecad-robust-mcp) -[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://spkane.github.io/freecad-robust-mcp-and-more/) +[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://spkane.github.io/freecad-addon-robust-mcp-server/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 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 Robust MCP Suite](#freecad-robust-mcp-suite) +- [FreeCAD Robust MCP Server](#freecad-robust-mcp-server) - [Table of Contents](#table-of-contents) - [Features](#features) - [Requirements](#requirements) @@ -59,10 +56,6 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that - [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) - - [Downloading Macros](#downloading-macros) - - [CutObjectForMagnets](#cutobjectformagnets) - - [MultiExport](#multiexport) - [For Developers](#for-developers) - [Robust MCP Server Development](#robust-mcp-server-development) - [Prerequisites](#prerequisites) @@ -74,9 +67,6 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that - [Headless Mode (for automation/CI)](#headless-mode-for-automationci) - [Running Tests](#running-tests) - [Code Quality](#code-quality) - - [Macro Development](#macro-development) - - [CutObjectForMagnets Macro](#cutobjectformagnets-macro) - - [MultiExport Macro](#multiexport-macro) - [Architecture](#architecture) - [Acknowledgements](#acknowledgements) - [Related Projects](#related-projects) @@ -90,7 +80,6 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that - **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 Robust MCP Server ## Requirements @@ -101,20 +90,20 @@ An [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server that ## For Users -This section covers installation and usage for end users who want to use the Robust MCP Server with AI assistants or the standalone FreeCAD macros. +This section covers installation and usage for end users who want to use the Robust MCP Server with AI assistants. ### Quick Links -| Resource | Description | -| --------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| [**Documentation**](https://spkane.github.io/freecad-robust-mcp-and-more/) | Full documentation, guides, and API reference | -| [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) | Pre-built Docker images for easy deployment | -| [PyPI](https://pypi.org/project/freecad-robust-mcp/) | Python package for pip installation | -| [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) | Release archives, changelogs, and standalone macro downloads | +| Resource | Description | +| ------------------------------------------------------------------------------------- | --------------------------------------------- | +| [**Documentation**](https://spkane.github.io/freecad-addon-robust-mcp-server/) | Full documentation, guides, and API reference | +| [Docker Hub](https://hub.docker.com/r/spkane/freecad-robust-mcp) | Pre-built Docker images for easy deployment | +| [PyPI](https://pypi.org/project/freecad-robust-mcp/) | Python package for pip installation | +| [GitHub Releases](https://github.com/spkane/freecad-addon-robust-mcp-server/releases) | Release archives and changelogs | ## Robust MCP Server -> **Note**: Since this repository has more than just the Robust MCP Server in it, the Linux container and PyPi projects releases are both simply named `freecad-robust-mcp` which differs from the name of this git repository. +> **Note**: The Linux container and PyPI package are both named `freecad-robust-mcp` which differs slightly from this git repository name. ### Installation @@ -127,8 +116,8 @@ pip install freecad-robust-mcp #### Using mise and just (from source) ```bash -git clone https://github.com/spkane/freecad-robust-mcp-and-more.git -cd freecad-robust-mcp-and-more +git clone https://github.com/spkane/freecad-addon-robust-mcp-server.git +cd freecad-addon-robust-mcp-server # Install mise via the Official mise installer script (if not already installed) curl https://mise.run | sh @@ -147,8 +136,8 @@ Run the Robust MCP Server in a container. This is useful for isolated environmen docker pull spkane/freecad-robust-mcp # Or build locally -git clone https://github.com/spkane/freecad-robust-mcp-and-more.git -cd freecad-robust-mcp-and-more +git clone https://github.com/spkane/freecad-addon-robust-mcp-server.git +cd freecad-addon-robust-mcp-server docker build -t freecad-robust-mcp . # Or use just commands (if you have mise/just installed) @@ -205,7 +194,7 @@ If installed from source with mise/uv: "mcpServers": { "freecad": { "command": "/path/to/mise/shims/uv", - "args": ["run", "--project", "/path/to/freecad-robust-mcp-and-more", "freecad-mcp"], + "args": ["run", "--project", "/path/to/freecad-addon-robust-mcp-server", "freecad-mcp"], "env": { "FREECAD_MODE": "xmlrpc" } @@ -491,132 +480,6 @@ The Robust MCP Server provides **83 tools** organized into categories. Tools mar --- -## FreeCAD Macros - -This project includes standalone FreeCAD macros that can be used independently of the Robust MCP Server. These are useful for FreeCAD users who want the macros without setting up the full MCP integration. - -### Downloading Macros - -Pre-packaged macro archives are available with each release: - -1. Go to the [Releases page](https://github.com/spkane/freecad-robust-mcp-and-more/releases) -1. Download the macro archive for your platform: - - `freecad-macros-X.Y.Z.tar.gz` (Linux/macOS) - - `freecad-macros-X.Y.Z.zip` (Windows) -1. Extract and copy the `.FCMacro` files to your FreeCAD macro directory: - - **macOS**: `~/Library/Application Support/FreeCAD/Macro/` - - **Linux**: `~/.local/share/FreeCAD/Macro/` - - **Windows**: `%APPDATA%/FreeCAD/Macro/` -1. **(Optional)** Copy the `.svg` icon files to the same directory for custom icons in FreeCAD's macro menu - -### 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 -``` - -### MultiExport - -Export selected FreeCAD objects to multiple file formats simultaneously. Supports 8 formats with a convenient checkbox dialog and smart defaults. - -**Features:** - -- Export to 8 formats: STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF -- Smart defaults: STL, STEP, and 3MF pre-selected -- Intelligent path defaults based on document location -- Configurable mesh quality (tolerance and deflection) -- Real-time file preview before export -- Multi-object batch export support - -**Installation:** - -```bash -# If you have the source: -just install::macro-export - -# Or manually copy MultiExport.FCMacro from macros/Multi_Export/ -# to your FreeCAD macro directory: -# macOS: ~/Library/Application Support/FreeCAD/Macro/ -# Linux: ~/.local/share/FreeCAD/Macro/ -# Windows: %APPDATA%/FreeCAD/Macro/ -``` - -**Usage:** - -1. Select one or more objects in FreeCAD (Ctrl+click for multiple) -1. Go to **Macro -> Macros... -> MultiExport -> Execute** -1. Choose export formats (checkboxes) -1. Set output directory and base filename -1. Adjust mesh quality if needed -1. Click **Export** - -**Supported Formats:** - -| Format | Extension | Default | Description | -| ------ | --------- | ------- | ------------------------------------ | -| STL | `.stl` | Yes | Standard 3D printing format | -| STEP | `.step` | Yes | CAD interchange (preserves geometry) | -| 3MF | `.3mf` | Yes | Modern 3D printing with metadata | -| OBJ | `.obj` | No | 3D graphics and game engines | -| IGES | `.iges` | No | Legacy CAD interchange | -| BREP | `.brep` | No | OpenCASCADE native format | -| PLY | `.ply` | No | Polygon file for 3D scanning | -| AMF | `.amf` | No | Additive manufacturing format | - -See [macros/Multi_Export/README-MultiExport.md](macros/Multi_Export/README-MultiExport.md) for detailed documentation. - -**Uninstall:** - -```bash -just freecad::uninstall-export-macro -``` - ---- - ## For Developers This section covers development setup, contributing, and working with the codebase. @@ -632,8 +495,8 @@ This section covers development setup, contributing, and working with the codeba ```bash # Clone the repository -git clone https://github.com/spkane/freecad-robust-mcp-and-more.git -cd freecad-robust-mcp-and-more +git clone https://github.com/spkane/freecad-addon-robust-mcp-server.git +cd freecad-addon-robust-mcp-server # Install mise via the Official mise installer script (if not already installed) curl https://mise.run | sh @@ -662,7 +525,7 @@ Create a `.mcp.json` file in the project directory: "mcpServers": { "freecad": { "command": "/path/to/mise/shims/uv", - "args": ["run", "--project", "/path/to/freecad-robust-mcp-and-more", "freecad-mcp"], + "args": ["run", "--project", "/path/to/freecad-addon-robust-mcp-server", "freecad-mcp"], "env": { "FREECAD_MODE": "xmlrpc", "FREECAD_SOCKET_HOST": "localhost", @@ -676,11 +539,11 @@ Create a `.mcp.json` file in the project directory: **Replace the paths with your actual paths:** -| Placeholder | Description | Example | -| -------------------------------------- | ------------------------------ | ------------------------------------------ | -| `/path/to/mise/shims/uv` | Full path to uv via mise shims | `~/.local/share/mise/shims/uv` | -| `/path/to/freecad-robust-mcp-and-more` | Project directory | `/home/me/dev/freecad-robust-mcp-and-more` | -| `/path/to/mise/shims` | mise shims directory for PATH | `~/.local/share/mise/shims` | +| Placeholder | Description | Example | +| ------------------------------------------- | ------------------------------- | ---------------------------------------------- | +| `/path/to/mise/shims/uv` | Full path to uv via mise shims | `~/.local/share/mise/shims/uv` | +| `/path/to/freecad-addon-robust-mcp-server` | Project directory | `/home/me/dev/freecad-addon-robust-mcp-server` | +| `/path/to/mise/shims` | mise shims directory for PATH | `~/.local/share/mise/shims` | **Finding your mise shims path:** @@ -789,46 +652,6 @@ just quality::secrets --- -## Macro Development - -### CutObjectForMagnets Macro - -**Location:** `macros/Cut_Object_for_Magnets/` - -**Installation for development:** - -```bash -just install::macro-cut -``` - -**Uninstall:** - -```bash -just install::uninstall-macro-cut -``` - -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. - -### MultiExport Macro - -**Location:** `macros/Multi_Export/` - -**Installation for development:** - -```bash -just install::macro-export -``` - -**Uninstall:** - -```bash -just install::uninstall-macro-export -``` - -See [macros/Multi_Export/README-MultiExport.md](macros/Multi_Export/README-MultiExport.md) for detailed documentation on the macro's internals. - ---- - ## Architecture See the [detailed architecture document](docs/development/architecture-detailed.md) for design documentation covering: diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 53ddd6f..001c27d 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -259,6 +259,89 @@ Embedded mode receives **minimal testing**: --- +## Future Considerations + +### Bundled vs. Separate Server Architecture + +The current architecture keeps the MCP server separate from the FreeCAD addon/workbench. This section documents the trade-offs and potential future directions. + +#### Current Approach: Separate Components + +```text +┌─────────────────┐ XML-RPC/Socket ┌─────────────────┐ +│ MCP Server │◄──────────────────────►│ FreeCAD │ +│ (separate venv) │ │ (+ Workbench) │ +└─────────────────┘ └─────────────────┘ +``` + +**Advantages:** + +- Server runs in its own Python environment with full control over dependencies +- Allows remote server scenarios (AI/server on powerful machine, FreeCAD on workstation) +- Server can be updated independently of the addon +- No dependency conflicts with FreeCAD's embedded Python +- Easier testing and development + +**Disadvantages:** + +- Users must install two components separately +- More complex setup process +- Need to manage version compatibility between server and workbench + +#### Potential Future: Bundled Server in Addon + +```text +┌─────────────────────────────────────────┐ +│ FreeCAD Addon │ +│ ┌─────────────┐ ┌─────────────────┐ │ +│ │ Workbench │◄──►│ Bundled Server │ │ +│ │ (GUI) │ │ (subprocess) │ │ +│ └─────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +A bundled approach could: + +- Provide single-install experience from FreeCAD Addon Manager +- Auto-start server when workbench loads +- Still support "Remote Server" mode for advanced users via preferences + +**Implementation considerations:** + +1. **Dependency management**: Server requires `fastmcp`, `httpx`, `uvicorn`, etc. These may conflict with FreeCAD's Python. Options: + - Bundle dependencies in addon (vendor them) + - Use subprocess with bundled `requirements.txt` and pip install on first run + - Create a minimal server that uses only stdlib + +2. **Startup modes**: + + ```python + if preferences.use_remote_server: + connect_to(preferences.server_url) + else: + # Start bundled server in subprocess + subprocess.Popen([sys.executable, "-m", "robust_mcp_server"]) + ``` + +3. **Hybrid approach**: Default to bundled local server, but expose preferences for remote server URL (host:port) for advanced deployments. + +#### Decision + +Currently maintaining separate components because: + +- Cleaner separation of concerns +- Proven reliability across platforms +- Easier to develop and test independently +- Remote server use case, while less common, is valuable for some workflows + +May revisit bundling in a future major version when: + +- Dependency requirements stabilize +- User feedback indicates strong preference for single-install +- A clean subprocess-based bundling approach is validated + +--- + ## Next Steps - [Contributing](contributing.md) - How to contribute diff --git a/docs/development/contributing.md b/docs/development/contributing.md index c33f840..053625f 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -89,7 +89,6 @@ freecad-robust-mcp-and-more/ │ └── server.py # Main server entry point ├── addon/ # FreeCAD workbench addon │ └── FreecadRobustMCPBridge/ # Workbench files -├── macros/ # Standalone FreeCAD macros ├── tests/ # Test suite │ ├── unit/ # Unit tests │ └── integration/ # Integration tests diff --git a/docs/development/releasing.md b/docs/development/releasing.md index c49bb52..6d067ff 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -1,6 +1,6 @@ # Release Process -This project uses **component-specific versioning**. Each component (MCP Server, Workbench, Macros) has its own version and release cycle, allowing independent updates without affecting other components. +This project uses **component-specific versioning**. Each component (MCP Server, Workbench) has its own version and release cycle, allowing independent updates without affecting other components. ## Quick Start @@ -9,46 +9,39 @@ The complete release workflow in order: ```bash # 1. Pre-release checks just release::status # Check which components have unreleased changes -just release::changes-since mcp-server # View specific changes (or workbench, macro-magnets, macro-export) +just release::changes-since mcp-server # View specific changes (or workbench) just all # Run all quality checks (must pass) # 2. Update release notes just release::draft-notes mcp-server # Generate draft notes from commits # Then edit the component's RELEASE_NOTES.md file (see "Release Notes Files" below) -# 3. Version bump (workbench & macros only - MCP Server uses setuptools-scm) -just release::bump-workbench 1.0.0 # or bump-macro-magnets, bump-macro-export +# 3. Version bump (workbench only - MCP Server uses setuptools-scm) +just release::bump-workbench 1.0.0 # 4. Commit changes git add -A git commit -m "chore: bump workbench to 1.0.0" # or appropriate component/message # 5. Create & push tag (triggers CI/CD automatically) -just release::tag-workbench 1.0.0 # or tag-mcp-server, tag-macro-magnets, tag-macro-export +just release::tag-workbench 1.0.0 # or tag-mcp-server # 6. Monitor release at GitHub Actions, then verify just release::list-tags just release::latest-versions - -# 7. Update FreeCAD wiki (macros only) -just release::wiki-update macro-magnets # Copies content to clipboard & opens wiki edit page ``` -| Component | Bump Command | Tag Command | -| ------------- | ---------------------------------------- | --------------------------------------- | -| MCP Server | *(none - uses setuptools-scm)* | `just release::tag-mcp-server X.Y.Z` | -| Workbench | `just release::bump-workbench X.Y.Z` | `just release::tag-workbench X.Y.Z` | -| Magnets Macro | `just release::bump-macro-magnets X.Y.Z` | `just release::tag-macro-magnets X.Y.Z` | -| Export Macro | `just release::bump-macro-export X.Y.Z` | `just release::tag-macro-export X.Y.Z` | +| Component | Bump Command | Tag Command | +| ---------- | ------------------------------------ | ------------------------------------ | +| MCP Server | *(none - uses setuptools-scm)* | `just release::tag-mcp-server X.Y.Z` | +| Workbench | `just release::bump-workbench X.Y.Z` | `just release::tag-workbench X.Y.Z` | ## Components and Their Release Targets -| Component | Tag Format | Releases To | -| ---------------------------- | ------------------------------------- | ------------------------------------------ | -| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release | -| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) | -| Cut Object for Magnets Macro | `macro-cut-object-for-magnets-vX.Y.Z` | GitHub Release (archive) | -| Multi Export Macro | `macro-multi-export-vX.Y.Z` | GitHub Release (archive) | +| Component | Tag Format | Releases To | +| --------------------------- | ----------------------------- | ------------------------------------------ | +| MCP Server | `robust-mcp-server-vX.Y.Z` | PyPI/TestPyPI*, Docker Hub, GitHub Release | +| Robust MCP Bridge Workbench | `robust-mcp-workbench-vX.Y.Z` | GitHub Release (archive) | *Stable releases (`X.Y.Z`) publish to PyPI; non-stable releases (alpha, beta, rc) publish to TestPyPI only. @@ -110,10 +103,6 @@ just release::changes-since mcp-server # For Workbench just release::changes-since workbench - -# For Macros -just release::changes-since macro-magnets -just release::changes-since macro-export ``` ### 3. Run All Quality Checks @@ -136,12 +125,10 @@ Each component has its own `RELEASE_NOTES.md` file. Release workflows automatica #### Release Notes Files -| Component | Release Notes File | -| ---------------------------- | ----------------------------------------------------- | -| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | -| Robust MCP Bridge Workbench | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | -| Cut Object for Magnets Macro | `macros/Cut_Object_for_Magnets/RELEASE_NOTES.md` | -| Multi Export Macro | `macros/Multi_Export/RELEASE_NOTES.md` | +| Component | Release Notes File | +| --------------------------- | ----------------------------------------------- | +| MCP Server | `src/freecad_mcp/RELEASE_NOTES.md` | +| Robust MCP Bridge Workbench | `addon/FreecadRobustMCPBridge/RELEASE_NOTES.md` | #### Draft Release Notes @@ -151,8 +138,6 @@ Use the `draft-notes` command to generate a starting point from conventional com # Generate draft notes for a component just release::draft-notes mcp-server just release::draft-notes workbench -just release::draft-notes macro-magnets -just release::draft-notes macro-export ``` This categorizes commits by type (feat, fix, refactor, etc.) to help you write the release notes. @@ -256,61 +241,13 @@ just release::tag-workbench 1.0.0 3. Creates archive (tar.gz and zip) 4. Creates GitHub Release with the archives -### Macro Releases - -Each macro can be released independently. - -**Cut Object for Magnets Macro:** - -```bash -# 1. Bump version in source files -just release::bump-macro-magnets 1.0.0 - -# 2. Review and commit the changes -git diff # Review changes -git add -A -git commit -m "chore: bump Cut Object for Magnets macro to 1.0.0" - -# 3. Create and push the release tag -just release::tag-macro-magnets 1.0.0 -``` - -**Multi Export Macro:** - -```bash -# 1. Bump version in source files -just release::bump-macro-export 1.0.0 - -# 2. Review and commit the changes -git diff # Review changes -git add -A -git commit -m "chore: bump Multi Export macro to 1.0.0" - -# 3. Create and push the release tag -just release::tag-macro-export 1.0.0 -``` - -**Files updated by macro bump commands:** - -- `macros//.FCMacro` (`__Version__` and `__Date__`) -- `macros//README-.md` (`**Version:**`) -- `macros//wiki-source.txt` (`|Version=` and `|Date=`) -- `package.xml` (macro section: `` and ``) - -**What happens automatically:** - -1. GitHub Actions validates the tag format -2. Verifies version in source files matches tag -3. Creates archive (tar.gz and zip) -4. Creates GitHub Release with the archives - ## Verifying a Release ### Check GitHub Actions After pushing a tag, monitor the release workflow: -1. Go to [GitHub Actions](https://github.com/spkane/freecad-robust-mcp-and-more/actions) +1. Go to [GitHub Actions](https://github.com/spkane/freecad-addon-robust-mcp-server/actions) 2. Find the workflow run triggered by your tag 3. Verify all steps complete successfully @@ -326,7 +263,7 @@ pip index versions freecad-robust-mcp docker pull spkane/freecad-robust-mcp:1.0.0 ``` -**For Workbench/Macros:** +**For Workbench:** - Check the GitHub Releases page for the archive downloads - Verify the `package.xml` version was updated @@ -361,7 +298,6 @@ To see what a release tag would look like without creating it: ```bash just release::dry-run-tag mcp-server 1.0.0 just release::dry-run-tag workbench 1.0.0 -just release::dry-run-tag macro-magnets 1.0.0 ``` ## Troubleshooting @@ -416,56 +352,6 @@ just release::tag-mcp-server 1.0.0 - **MCP Server**: Release when there are significant new features or important bug fixes - **Workbench**: Release in sync with server changes that affect the bridge protocol -- **Macros**: Release independently when macro functionality changes - -## Updating the FreeCAD Wiki - -After releasing a macro, you should update its FreeCAD wiki page. The `wiki-source.txt` files are automatically updated by the `bump-macro-*` commands with the new version and date. - -### Wiki Update Commands - -```bash -# Check differences between local and live wiki -just release::wiki-diff macro-magnets -just release::wiki-diff macro-export - -# View the wiki source content locally -just release::wiki-show macro-magnets -just release::wiki-show macro-export - -# Update the wiki (copies to clipboard and opens edit page) -just release::wiki-update macro-magnets -just release::wiki-update macro-export -``` - -### Wiki Update Workflow - -The `wiki-update` command provides a safe, assisted workflow: - -1. Copies the updated wiki-source.txt content to your clipboard -2. Opens the FreeCAD wiki edit page in your browser -3. Displays step-by-step instructions - -**Manual steps after running the command:** - -1. Log in to your FreeCAD wiki account if prompted -2. Select all content in the edit box (Ctrl+A / Cmd+A) -3. Paste the new content (Ctrl+V / Cmd+V) -4. Add an edit summary like "Update to version X.Y.Z" -5. Click "Show preview" to verify changes -6. Click "Save changes" when satisfied - -!!! note "Wiki Account Required" - You need a FreeCAD wiki account to edit pages. Register at [wiki.freecad.org](https://wiki.freecad.org) if you don't have one. - -### Macro Shortcuts - -The wiki commands accept multiple aliases for convenience: - -| Macro | Aliases | -| ------------------------ | -------------------------------- | -| Cut Object for Magnets | `macro-magnets`, `magnets`, `cut`| -| Multi Export | `macro-export`, `export`, `multi`| ## Quick Reference @@ -479,13 +365,9 @@ just release::changes-since mcp-server # Draft release notes from commits just release::draft-notes mcp-server just release::draft-notes workbench -just release::draft-notes macro-magnets -just release::draft-notes macro-export -# Bump versions (for workbench and macros) +# Bump versions (for workbench only) just release::bump-workbench 1.0.0 -just release::bump-macro-magnets 1.0.0 -just release::bump-macro-export 1.0.0 # Commit version changes git add -A && git commit -m "chore: bump to X.Y.Z" @@ -493,8 +375,6 @@ git add -A && git commit -m "chore: bump to X.Y.Z" # Create releases (verifies versions, creates and pushes tag) just release::tag-mcp-server 1.0.0 just release::tag-workbench 1.0.0 -just release::tag-macro-magnets 1.0.0 -just release::tag-macro-export 1.0.0 # List existing releases just release::list-tags @@ -503,11 +383,6 @@ just release::latest-versions # Extract changelog for a version (used by CI) just release::extract-changelog mcp-server 1.0.0 -# Update FreeCAD wiki for macros (after release) -just release::wiki-diff macro-magnets # Check differences -just release::wiki-update macro-magnets # Copy to clipboard & open edit page -just release::wiki-update macro-export - # Delete a tag if needed just release::delete-tag ``` diff --git a/docs/guide/macros.md b/docs/guide/macros.md index 6e43638..8c20e67 100644 --- a/docs/guide/macros.md +++ b/docs/guide/macros.md @@ -1,78 +1,10 @@ -# FreeCAD Macros +# MCP Macro Tools -This project includes standalone FreeCAD macros that work independently of the MCP server, plus MCP tools for creating and managing macros programmatically. +The MCP server provides tools for working with FreeCAD macros programmatically. --- -## Included Macros - -### CutObjectForMagnets - -Cut an object along a plane and add aligned magnet holes with surface collision detection. Perfect for creating 3D printed parts that snap together with embedded magnets. - -**Features:** - -- Interactive plane selection via GUI -- Automatic magnet hole placement with configurable grid -- Surface collision detection to avoid invalid hole positions -- Configurable magnet dimensions and tolerances - -**Usage:** - -1. Select an object in FreeCAD -1. Run the macro -1. Define the cutting plane interactively -1. Configure magnet parameters -1. The macro creates two halves with aligned magnet holes - -See [CutObjectForMagnets documentation](https://github.com/spkane/freecad-robust-mcp-and-more/tree/main/macros/Cut_Object_for_Magnets) for detailed usage. - -### MultiExport - -Export selected bodies to multiple file formats simultaneously with configurable mesh options. - -**Supported Formats:** - -- STL (ASCII and Binary) -- STEP -- 3MF -- OBJ -- IGES -- BREP -- PLY -- AMF - -**Usage:** - -1. Select one or more bodies/parts -1. Run the macro -1. Select output formats and configure mesh options -1. Choose output directory -1. All exports are created with consistent naming - -See [MultiExport documentation](https://github.com/spkane/freecad-robust-mcp-and-more/tree/main/macros/Multi_Export) for detailed usage. - ---- - -## Installing Macros - -### Via FreeCAD Addon Manager - -When you install the "FreeCAD Robust MCP Suite" addon, the macros are installed automatically. - -### Manual Installation - -1. Download macros from [GitHub Releases](https://github.com/spkane/freecad-robust-mcp-and-more/releases) -1. Copy `.FCMacro` files to your macro directory: - - **Linux:** `~/.local/share/FreeCAD/Macro/` - - **macOS:** `~/Library/Application Support/FreeCAD/Macro/` - - **Windows:** `%APPDATA%\FreeCAD\Macro\` - ---- - -## MCP Macro Tools - -The MCP server provides tools for working with macros programmatically: +## Available Tools ### list_macros @@ -98,7 +30,7 @@ run_macro( **Example prompt:** ```text -"Run the MultiExport macro" +"Run the ExportSTL macro" ``` ### create_macro @@ -130,7 +62,7 @@ read_macro(macro_name: str) -> dict **Example prompt:** ```text -"Show me the code for the MultiExport macro" +"Show me the code for my custom macro" ``` ### delete_macro diff --git a/docs/guide/resources.md b/docs/guide/resources.md index e2e5f95..97adb2e 100644 --- a/docs/guide/resources.md +++ b/docs/guide/resources.md @@ -237,9 +237,9 @@ Gets list of available FreeCAD macros. ```json [ { - "name": "MultiExport", - "path": "/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro", - "description": "Export objects to multiple formats", + "name": "ExportSTL", + "path": "/home/user/.local/share/FreeCAD/Macro/ExportSTL.FCMacro", + "description": "Export selected objects to STL", "is_system": false } ] diff --git a/docs/guide/workbench.md b/docs/guide/workbench.md index 9f5dd72..13c60ca 100644 --- a/docs/guide/workbench.md +++ b/docs/guide/workbench.md @@ -255,11 +255,6 @@ The workbench uses a **queue-based thread safety system** to ensure FreeCAD oper --- -## Included Macros +## Macro Tools -The addon bundle also includes standalone FreeCAD macros: - -- **MultiExport** - Export objects to multiple formats simultaneously -- **CutObjectForMagnets** - Cut objects with aligned magnet holes for 3D printing - -See [Macros](macros.md) for details. +The MCP server provides tools for working with FreeCAD macros. See [Macros](macros.md) for details on using macro tools. diff --git a/docs/index.md b/docs/index.md index 9a3ba8c..0a180ae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ Welcome to the FreeCAD Robust MCP Suite documentation. -This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server, FreeCAD workbench, and standalone macros that enable 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. +This project provides an [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server and FreeCAD workbench that enable 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. --- @@ -12,7 +12,6 @@ This project provides an [MCP (Model Context Protocol)](https://modelcontextprot - **Multiple Connection Modes** - XML-RPC (recommended), JSON-RPC socket, or embedded (Linux only) - **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 Robust MCP Server --- @@ -64,10 +63,13 @@ The Robust MCP Server works with FreeCAD in both GUI and headless mode: ## FreeCAD Macros -This project includes standalone FreeCAD macros: +The MCP server provides tools for working with FreeCAD macros: -- **[CutObjectForMagnets](guide/macros.md#cutobjectformagnets)** - Cuts objects along planes with automatic magnet hole placement -- **[MultiExport](guide/macros.md#multiexport)** - Export objects to multiple formats simultaneously +- **List macros** - Discover available macros in your FreeCAD installation +- **Run macros** - Execute macros with parameter passing +- **Create macros** - Generate new macros from templates or custom code + +See [Macros Guide](guide/macros.md) for details on using macros with the MCP server. --- diff --git a/just/freecad.just b/just/freecad.just index 01f15c1..3a97877 100644 --- a/just/freecad.just +++ b/just/freecad.just @@ -196,8 +196,5 @@ run-gui-custom freecad_path: # ============================================================================= # These are kept for backwards compatibility but will be removed in a future version. # Use the new install:: module commands instead: -# just install::macro-cut (was: just freecad::install-cut-macro) -# just install::macro-export (was: just freecad::install-export-macro) -# just install::macro-all (was: just freecad::install-all-macros) # just install::mcp-bridge-workbench (was: just freecad::install-workbench) # just install::status (was: just freecad::mcp-status) diff --git a/just/install.just b/just/install.just index 97d0423..7de0729 100644 --- a/just/install.just +++ b/just/install.just @@ -4,7 +4,6 @@ # This module installs components for end users: # - Robust MCP Server (as a uv tool, available system-wide) # - Robust MCP Bridge Workbench (FreeCAD addon) -# - FreeCAD Macros (CutObjectForMagnets, MultiExport) # # For developer setup (Python dependencies in virtualenv), use: just dev::install-deps @@ -320,132 +319,6 @@ uninstall-mcp-bridge-workbench: echo "Workbench not found at: $ADDON_DEST" fi -# ============================================================================= -# Macro Installation -# ============================================================================= - -# Install the CutObjectForMagnets macro to FreeCAD's macro directory -macro-cut: - #!/usr/bin/env bash - set -euo pipefail - PROJECT_DIR="{{project_root}}" - - # Set FreeCAD directories - eval "$(just install::_freecad-dirs)" - - # Verify source exists before copying - MACRO_SRC="${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro" - if [[ ! -f "$MACRO_SRC" ]]; then - echo "Error: Macro source file not found: $MACRO_SRC" >&2 - exit 1 - fi - - mkdir -p "$MACRO_DIR" - - # Remove existing installation if present (clean install) - if [[ -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" ]]; then - echo "Removing existing CutObjectForMagnets macro..." - rm -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" - rm -f "$MACRO_DIR/CutObjectForMagnets.svg" - fi - - # Copy the macro file - cp "$MACRO_SRC" "$MACRO_DIR/" - - # Optionally copy the icon if it exists - ICON_SRC="${PROJECT_DIR}/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg" - if [[ -f "$ICON_SRC" ]]; then - cp "$ICON_SRC" "$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-macro-cut: - #!/usr/bin/env bash - set -euo pipefail - - # Set FreeCAD directories - eval "$(just install::_freecad-dirs)" - - rm -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" - rm -f "$MACRO_DIR/CutObjectForMagnets.svg" - echo "CutObjectForMagnets macro uninstalled" - -# Install the MultiExport macro to FreeCAD's macro directory -macro-export: - #!/usr/bin/env bash - set -euo pipefail - PROJECT_DIR="{{project_root}}" - - # Set FreeCAD directories - eval "$(just install::_freecad-dirs)" - - # Verify source exists before copying - MACRO_SRC="${PROJECT_DIR}/macros/Multi_Export/MultiExport.FCMacro" - if [[ ! -f "$MACRO_SRC" ]]; then - echo "Error: Macro source file not found: $MACRO_SRC" >&2 - exit 1 - fi - - mkdir -p "$MACRO_DIR" - - # Remove existing installation if present (clean install) - if [[ -f "$MACRO_DIR/MultiExport.FCMacro" ]]; then - echo "Removing existing MultiExport macro..." - rm -f "$MACRO_DIR/MultiExport.FCMacro" - rm -f "$MACRO_DIR/MultiExport.svg" - fi - - # Copy the macro file - cp "$MACRO_SRC" "$MACRO_DIR/" - - # Optionally copy the icon if it exists - ICON_SRC="${PROJECT_DIR}/macros/Multi_Export/MultiExport.svg" - if [[ -f "$ICON_SRC" ]]; then - cp "$ICON_SRC" "$MACRO_DIR/" - fi - - echo "MultiExport macro installed to: $MACRO_DIR" - echo "" - echo "To use:" - echo " 1. Start FreeCAD" - echo " 2. Select one or more objects to export" - echo " 3. Go to: Macro → Macros → MultiExport → Execute" - echo " 4. Choose export formats (STL, STEP, 3MF selected by default)" - echo " 5. Set output directory and filename" - echo " 6. Click Export" - -# Uninstall the MultiExport macro -uninstall-macro-export: - #!/usr/bin/env bash - set -euo pipefail - - # Set FreeCAD directories - eval "$(just install::_freecad-dirs)" - - rm -f "$MACRO_DIR/MultiExport.FCMacro" - rm -f "$MACRO_DIR/MultiExport.svg" - echo "MultiExport macro uninstalled" - -# Install all macros to FreeCAD's macro directory -macro-all: macro-cut macro-export - @echo "All macros installed successfully!" - -# Uninstall all macros from FreeCAD's macro directory -uninstall-macro-all: uninstall-macro-cut uninstall-macro-export - @echo "All macros uninstalled successfully!" - # ============================================================================= # Status Check # ============================================================================= @@ -521,23 +394,6 @@ status: ' 2>/dev/null || echo "unknown" } - # Helper function to extract __Version__ from macro files (handles single/double quotes) - # Uses environment variable to pass file path safely to Python (avoids shell interpolation) - extract_macro_version() { - local file="$1" - MACRO_FILE="$file" python3 -c ' - import os - import re - try: - macro_file = os.environ.get("MACRO_FILE", "") - content = open(macro_file).read() - match = re.search(r"__Version__\s*=\s*[\"'"'"']([^\"'"'"']+)[\"'"'"']", content) - print(match.group(1) if match else "unknown") - except Exception: - print("unknown") - ' 2>/dev/null || echo "unknown" - } - # Check workbench if [[ -d "$MOD_DIR/FreecadRobustMCPBridge" ]]; then WB_VERSION="unknown" @@ -555,32 +411,6 @@ status: fi echo "" - # Check macros - - echo "Macros:" - if [[ -f "$MACRO_DIR/CutObjectForMagnets.FCMacro" ]]; then - CUT_VERSION=$(extract_macro_version "$MACRO_DIR/CutObjectForMagnets.FCMacro") - CUT_MOD_TIME=$(get_mod_time "$MACRO_DIR/CutObjectForMagnets.FCMacro") - echo " ✓ CutObjectForMagnets: INSTALLED" - echo " Version: $CUT_VERSION" - echo " Updated: $CUT_MOD_TIME" - else - echo " ✗ CutObjectForMagnets: NOT INSTALLED" - echo " Install: just install::macro-cut" - fi - - if [[ -f "$MACRO_DIR/MultiExport.FCMacro" ]]; then - EXPORT_VERSION=$(extract_macro_version "$MACRO_DIR/MultiExport.FCMacro") - EXPORT_MOD_TIME=$(get_mod_time "$MACRO_DIR/MultiExport.FCMacro") - echo " ✓ MultiExport: INSTALLED" - echo " Version: $EXPORT_VERSION" - echo " Updated: $EXPORT_MOD_TIME" - else - echo " ✗ MultiExport: NOT INSTALLED" - echo " Install: just install::macro-export" - fi - echo "" - # Check for legacy installations LEGACY_COUNT=0 @@ -602,3 +432,51 @@ status: fi echo "==========================================" + +# ============================================================================= +# Convenience Commands +# ============================================================================= + +# Uninstall all components (MCP server and workbench) +uninstall: + #!/usr/bin/env bash + set -euo pipefail + echo "Uninstalling all Robust MCP components..." + echo "" + just install::uninstall-mcp-server + echo "" + just install::uninstall-mcp-bridge-workbench + echo "" + echo "All components uninstalled." + +# Clean up everything (uninstall all + remove legacy installations) +cleanup: + #!/usr/bin/env bash + set -euo pipefail + + # Set FreeCAD directories + eval "$(just install::_freecad-dirs)" + + echo "Cleaning up all Robust MCP installations..." + echo "" + + # Uninstall current components + just install::uninstall + + echo "" + echo "Removing legacy installations..." + + # Remove legacy MCPBridge if present + if [[ -d "$MOD_DIR/MCPBridge" ]]; then + rm -rf "$MOD_DIR/MCPBridge" + echo " Removed: $MOD_DIR/MCPBridge" + fi + + # Remove legacy macro if present + if [[ -f "$MACRO_DIR/StartMCPBridge.FCMacro" ]]; then + rm "$MACRO_DIR/StartMCPBridge.FCMacro" + echo " Removed: $MACRO_DIR/StartMCPBridge.FCMacro" + fi + + echo "" + echo "Cleanup complete." diff --git a/just/release.just b/just/release.just index 8e4f1af..148c778 100644 --- a/just/release.just +++ b/just/release.just @@ -13,8 +13,6 @@ # This project uses component-specific git tags for releases: # - robust-mcp-server-vX.Y.Z (triggers PyPI, Docker, GitHub release) # - robust-mcp-workbench-vX.Y.Z (triggers workbench archive release) -# - macro-cut-object-for-magnets-vX.Y.Z -# - macro-multi-export-vX.Y.Z # # Version Format (SemVer 2.0): # - X.Y.Z - Stable release @@ -103,102 +101,6 @@ bump-workbench version: echo " 2. Commit: git add -A && git commit -m 'chore: bump workbench to $VERSION'" echo " 3. Tag and release: just release::tag-workbench $VERSION" -# Private helper recipe for bumping macro versions -# Parameters: macro_dir, macro_name, macro_file_basename, readme_basename, tag_command, version -[private] -_bump-macro macro_dir macro_name macro_file_basename readme_basename tag_command version: - #!/usr/bin/env bash - set -euo pipefail - - VERSION="{{version}}" - TODAY=$(date +%Y-%m-%d) - MACRO_DIR="{{project_root}}/macros/{{macro_dir}}" - MACRO_NAME="{{macro_name}}" - - # Validate version format (SemVer 2.0) - if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then - echo "ERROR: Version '$VERSION' is not valid semver (X.Y.Z or X.Y.Z-prerelease)" - echo "Examples: 1.0.0, 1.0.0-alpha, 1.0.0-beta.1, 1.0.0-rc.1" - exit 1 - fi - - echo "Bumping $MACRO_NAME Macro to version: $VERSION (date: $TODAY)" - echo "" - - # Update __Version__ and __Date__ in .FCMacro file - MACRO_FILE="$MACRO_DIR/{{macro_file_basename}}" - if [ -f "$MACRO_FILE" ]; then - sed "s/^__Version__ = [\"'].*[\"']/__Version__ = \"${VERSION}\"/" "$MACRO_FILE" > "$MACRO_FILE.tmp" && mv "$MACRO_FILE.tmp" "$MACRO_FILE" - sed "s/^__Date__ = [\"'].*[\"']/__Date__ = \"${TODAY}\"/" "$MACRO_FILE" > "$MACRO_FILE.tmp" && mv "$MACRO_FILE.tmp" "$MACRO_FILE" - echo "Updated $MACRO_FILE:" - grep -E "^__(Version|Date)__" "$MACRO_FILE" - else - echo "ERROR: File not found: $MACRO_FILE" - exit 1 - fi - - # Update README version line - README_FILE="$MACRO_DIR/{{readme_basename}}" - if [ -f "$README_FILE" ]; then - sed "s/^\*\*Version:\*\* .*/\*\*Version:\*\* ${VERSION}/" "$README_FILE" > "$README_FILE.tmp" && mv "$README_FILE.tmp" "$README_FILE" - echo "" - echo "Updated $README_FILE:" - grep "Version:" "$README_FILE" | head -1 - else - echo "WARNING: File not found: $README_FILE" - fi - - # Update wiki-source.txt version and date - WIKI_FILE="$MACRO_DIR/wiki-source.txt" - if [ -f "$WIKI_FILE" ]; then - sed "s/|Version=.*/|Version=${VERSION}/" "$WIKI_FILE" > "$WIKI_FILE.tmp" && mv "$WIKI_FILE.tmp" "$WIKI_FILE" - sed "s/|Date=.*/|Date=${TODAY}/" "$WIKI_FILE" > "$WIKI_FILE.tmp" && mv "$WIKI_FILE.tmp" "$WIKI_FILE" - echo "" - echo "Updated $WIKI_FILE:" - grep -E "^\|Version=|\|Date=" "$WIKI_FILE" - else - echo "WARNING: File not found: $WIKI_FILE" - fi - - # Update package.xml macro version - PACKAGE_XML="{{project_root}}/package.xml" - if [ -f "$PACKAGE_XML" ]; then - awk -v name="$MACRO_NAME" -v version="$VERSION" -v date="$TODAY" ' - // { in_macro=1 } - /<\/macro>/ { in_macro=0; found_name=0 } - in_macro && /.*<\/name>/ { - if (index($0, name) > 0) found_name=1 - } - in_macro && found_name && // { - gsub(/[^<]*<\/version>/, "" version "") - } - in_macro && found_name && // { - gsub(/[^<]*<\/date>/, "" date "") - } - { print } - ' "$PACKAGE_XML" > "$PACKAGE_XML.tmp" && mv "$PACKAGE_XML.tmp" "$PACKAGE_XML" - echo "" - echo "Updated $PACKAGE_XML ($MACRO_NAME section):" - grep -A4 ">$MACRO_NAME<" "$PACKAGE_XML" - else - echo "ERROR: File not found: $PACKAGE_XML" - exit 1 - fi - - echo "" - echo "Version bump complete!" - echo "" - echo "Next steps:" - echo " 1. Review changes: git diff" - echo " 2. Commit: git add -A && git commit -m 'chore: bump $MACRO_NAME macro to $VERSION'" - echo " 3. Tag and release: just release::{{tag_command}} $VERSION" - -# Bump the Cut Object for Magnets macro version in all source files -bump-macro-magnets version: (_bump-macro "Cut_Object_for_Magnets" "Cut Object for Magnets" "CutObjectForMagnets.FCMacro" "README-CutObjectForMagnets.md" "tag-macro-magnets" version) - -# Bump the Multi Export macro version in all source files -bump-macro-export version: (_bump-macro "Multi_Export" "Multi Export" "MultiExport.FCMacro" "README-MultiExport.md" "tag-macro-export" version) - # ============================================================================= # Tag Creation Commands # ============================================================================= @@ -235,7 +137,7 @@ tag-mcp-server version: git push origin "$TAG" echo "" echo "Tag $TAG created and pushed!" - echo "Watch the release at: https://github.com/spkane/freecad-robust-mcp-and-more/actions" + echo "Watch the release at: https://github.com/spkane/freecad-addon-robust-mcp-server/actions" # Create and push a release tag for the Robust MCP Bridge workbench tag-workbench version: @@ -314,101 +216,7 @@ tag-workbench version: git push origin "$TAG" echo "" echo "Tag $TAG created and pushed!" - echo "Watch the release at: https://github.com/spkane/freecad-robust-mcp-and-more/actions" - -# Private helper recipe for tagging macro releases -# Parameters: macro_dir, macro_name, macro_file_basename, tag_prefix, bump_command, tag_message, version -[private] -_tag-macro macro_dir macro_name macro_file_basename tag_prefix bump_command tag_message version: - #!/usr/bin/env bash - set -euo pipefail - - # Source shared release helper functions - . "{{project_root}}/scripts/release-helpers.sh" - - VERSION="{{version}}" - TAG="{{tag_prefix}}v{{version}}" - MACRO_DIR="{{project_root}}/macros/{{macro_dir}}" - MACRO_NAME="{{macro_name}}" - - # Run all pre-release checks (version format, main branch, up-to-date, clean tree) - pre_release_checks "$VERSION" || exit 1 - - # Verify version in source files matches tag version - echo "Verifying version in source files..." - - # Check .FCMacro file - MACRO_FILE="$MACRO_DIR/{{macro_file_basename}}" - MACRO_VERSION=$(grep -o '__Version__ = "[^"]*"' "$MACRO_FILE" | cut -d'"' -f2) - if [ "$MACRO_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in $MACRO_FILE" - echo " Expected: $VERSION" - echo " Found: $MACRO_VERSION" - echo "" - echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes." - exit 1 - fi - echo "✓ $MACRO_FILE: $MACRO_VERSION" - - # Check wiki-source.txt - WIKI_FILE="$MACRO_DIR/wiki-source.txt" - if [ -f "$WIKI_FILE" ]; then - WIKI_VERSION=$(grep -o '|Version=[^|]*' "$WIKI_FILE" | cut -d= -f2 | tr -d '\n') - if [ "$WIKI_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in $WIKI_FILE" - echo " Expected: $VERSION" - echo " Found: $WIKI_VERSION" - echo "" - echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes." - exit 1 - fi - echo "✓ $WIKI_FILE: $WIKI_VERSION" - fi - - # Check package.xml - PACKAGE_XML="{{project_root}}/package.xml" - PKG_VERSION=$(awk -v name="$MACRO_NAME" ' - // { in_macro=1 } - /<\/macro>/ { in_macro=0; found_name=0 } - in_macro && index($0, name) > 0 { found_name=1 } - in_macro && found_name && // { - gsub(/.*/, ""); gsub(/<\/version>.*/, ""); print; exit - } - ' "$PACKAGE_XML") - if [ "$PKG_VERSION" != "$VERSION" ]; then - echo "ERROR: Version mismatch in $PACKAGE_XML ($MACRO_NAME section)" - echo " Expected: $VERSION" - echo " Found: $PKG_VERSION" - echo "" - echo "Run 'just release::{{bump_command}} $VERSION' first, then commit the changes." - exit 1 - fi - - echo "Version verification passed!" - echo "" - echo "Creating tag: $TAG" - echo "" - echo "This will trigger:" - echo " - GitHub release with macro archive" - echo "" - read -p "Continue? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Aborted." - exit 1 - fi - - git tag -a "$TAG" -m "{{tag_message}} v{{version}}" - git push origin "$TAG" - echo "" - echo "Tag $TAG created and pushed!" - echo "Watch the release at: https://github.com/spkane/freecad-robust-mcp-and-more/actions" - -# Create and push a release tag for the Cut Object for Magnets macro -tag-macro-magnets version: (_tag-macro "Cut_Object_for_Magnets" "Cut Object for Magnets" "CutObjectForMagnets.FCMacro" "macro-cut-object-for-magnets-" "bump-macro-magnets" "Release Cut Object for Magnets Macro" version) - -# Create and push a release tag for the Multi Export macro -tag-macro-export version: (_tag-macro "Multi_Export" "Multi Export" "MultiExport.FCMacro" "macro-multi-export-" "bump-macro-export" "Release Multi Export Macro" version) + echo "Watch the release at: https://github.com/spkane/freecad-addon-robust-mcp-server/actions" # ============================================================================= # Tag Information Commands @@ -422,12 +230,6 @@ list-tags: echo "" echo "=== Robust MCP Bridge Workbench Releases ===" git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -10 - echo "" - echo "=== Cut Object for Magnets Macro Releases ===" - git tag -l 'macro-cut-object-for-magnets-v*' --sort=-v:refname | head -10 - echo "" - echo "=== Multi Export Macro Releases ===" - git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -10 # Show the latest version of each component latest-versions: @@ -436,12 +238,8 @@ latest-versions: echo "" SERVER_TAG=$(git tag -l 'robust-mcp-server-v*' --sort=-v:refname | head -n1) WORKBENCH_TAG=$(git tag -l 'robust-mcp-workbench-v*' --sort=-v:refname | head -n1) - MAGNETS_TAG=$(git tag -l 'macro-cut-object-for-magnets-v*' --sort=-v:refname | head -n1) - EXPORT_TAG=$(git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -n1) echo " Robust MCP Server: ${SERVER_TAG:-none}" echo " MCP Bridge Workbench: ${WORKBENCH_TAG:-none}" - echo " Macro Magnets: ${MAGNETS_TAG:-none}" - echo " Macro Export: ${EXPORT_TAG:-none}" # Show commits since the last release of a component changes-since component: @@ -457,17 +255,9 @@ changes-since component: PREFIX="robust-mcp-workbench-v" PATHS="addon/FreecadRobustMCPBridge" ;; - macro-magnets|magnets) - PREFIX="macro-cut-object-for-magnets-v" - PATHS="macros/Cut_Object_for_Magnets" - ;; - macro-export|export) - PREFIX="macro-multi-export-v" - PATHS="macros/Multi_Export" - ;; *) echo "Unknown component: {{component}}" - echo "Valid: mcp-server, workbench, macro-magnets, macro-export" + echo "Valid: mcp-server, server, workbench" exit 1 ;; esac @@ -601,16 +391,6 @@ show-release-info tag: echo "Component: Robust MCP Bridge Workbench" echo "Version: $VERSION" ;; - macro-cut-object-for-magnets-v*) - VERSION="${TAG#macro-cut-object-for-magnets-v}" - echo "Component: Cut Object for Magnets Macro" - echo "Version: $VERSION" - ;; - macro-multi-export-v*) - VERSION="${TAG#macro-multi-export-v}" - echo "Component: Multi Export Macro" - echo "Version: $VERSION" - ;; *) echo "Component: Unknown (not a recognized release tag format)" ;; @@ -640,14 +420,6 @@ rollback-release tag: COMPONENT="workbench" VERSION="${TAG#robust-mcp-workbench-v}" ;; - macro-cut-object-for-magnets-v*) - COMPONENT="macro-magnets" - VERSION="${TAG#macro-cut-object-for-magnets-v}" - ;; - macro-multi-export-v*) - COMPONENT="macro-export" - VERSION="${TAG#macro-multi-export-v}" - ;; *) COMPONENT="unknown" ;; @@ -712,20 +484,6 @@ rollback-release tag: echo "Rollback will fully clean up this release." echo "" ;; - macro-magnets) - echo "Component: Cut Object for Magnets Macro v$VERSION" - echo "" - echo "This release only creates a GitHub Release with archive files." - echo "Rollback will fully clean up this release." - echo "" - ;; - macro-export) - echo "Component: Multi Export Macro v$VERSION" - echo "" - echo "This release only creates a GitHub Release with archive files." - echo "Rollback will fully clean up this release." - echo "" - ;; *) echo "WARNING: Unrecognized tag format. Proceeding with generic rollback." echo "" @@ -858,7 +616,7 @@ rollback-release tag: echo " □ Docker Hub: Delete 'latest' tag (if this was the latest release)" echo " URL: https://hub.docker.com/repository/docker/spkane/freecad-robust-mcp/tags" ;; - workbench|macro-magnets|macro-export) + workbench) echo "COMPLETED (automated):" echo " ✓ GitHub Release deleted" echo " ✓ Git tag deleted (local and remote)" @@ -965,30 +723,6 @@ status: echo "Robust MCP Bridge Workbench: up to date (${WORKBENCH_TAG:-no releases})" fi echo "" - - # Macro Magnets - MAGNETS_CHANGES=$(count_changes "macro-cut-object-for-magnets-v" "macros/Cut_Object_for_Magnets") - MAGNETS_TAG=$(git tag -l 'macro-cut-object-for-magnets-v*' --sort=-v:refname | head -1) - if [ "$MAGNETS_CHANGES" -gt 0 ]; then - echo "Macro (Cut Object for Magnets): $MAGNETS_CHANGES unreleased commit(s)" - echo " Latest: ${MAGNETS_TAG:-none}" - echo " View: just release::changes-since macro-magnets" - else - echo "Macro (Cut Object for Magnets): up to date (${MAGNETS_TAG:-no releases})" - fi - echo "" - - # Macro Export - EXPORT_CHANGES=$(count_changes "macro-multi-export-v" "macros/Multi_Export") - EXPORT_TAG=$(git tag -l 'macro-multi-export-v*' --sort=-v:refname | head -1) - if [ "$EXPORT_CHANGES" -gt 0 ]; then - echo "Macro (Multi Export): $EXPORT_CHANGES unreleased commit(s)" - echo " Latest: ${EXPORT_TAG:-none}" - echo " View: just release::changes-since macro-export" - else - echo "Macro (Multi Export): up to date (${EXPORT_TAG:-no releases})" - fi - echo "" echo "==========================================" # ============================================================================= @@ -1011,19 +745,9 @@ draft-notes component: PATHS="addon/FreecadRobustMCPBridge" COMPONENT_NAME="Robust MCP Bridge Workbench" ;; - macro-magnets|magnets) - PREFIX="macro-cut-object-for-magnets-v" - PATHS="macros/Cut_Object_for_Magnets" - COMPONENT_NAME="Cut Object for Magnets Macro" - ;; - macro-export|export) - PREFIX="macro-multi-export-v" - PATHS="macros/Multi_Export" - COMPONENT_NAME="Multi Export Macro" - ;; *) echo "Unknown component: {{component}}" - echo "Valid: mcp-server, workbench, macro-magnets, macro-export" + echo "Valid: mcp-server, server, workbench" exit 1 ;; esac @@ -1089,14 +813,9 @@ extract-changelog component version: workbench) RELEASE_NOTES="{{project_root}}/addon/FreecadRobustMCPBridge/RELEASE_NOTES.md" ;; - macro-magnets|magnets) - RELEASE_NOTES="{{project_root}}/macros/Cut_Object_for_Magnets/RELEASE_NOTES.md" - ;; - macro-export|export) - RELEASE_NOTES="{{project_root}}/macros/Multi_Export/RELEASE_NOTES.md" - ;; *) echo "Unknown component: {{component}}" + echo "Valid: mcp-server, server, workbench" exit 1 ;; esac @@ -1142,19 +861,9 @@ dry-run-tag component version: echo "Would create tag: $TAG" echo "Triggers: GitHub Release with workbench archive" ;; - macro-magnets|magnets) - TAG="macro-cut-object-for-magnets-v{{version}}" - echo "Would create tag: $TAG" - echo "Triggers: GitHub Release with macro archive" - ;; - macro-export|export) - TAG="macro-multi-export-v{{version}}" - echo "Would create tag: $TAG" - echo "Triggers: GitHub Release with macro archive" - ;; *) echo "Unknown component: {{component}}" - echo "Valid: mcp-server, workbench, macro-magnets, macro-export" + echo "Valid: mcp-server, server, workbench" exit 1 ;; esac @@ -1174,19 +883,9 @@ wiki-update component: WIKI_PAGE="Robust_MCP_Bridge_Workbench" COMPONENT_NAME="Robust MCP Bridge Workbench" ;; - macro-magnets|magnets|cut) - WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" - WIKI_PAGE="Macro_Cut_Object_for_Magnets" - COMPONENT_NAME="Cut Object for Magnets" - ;; - macro-export|export|multi) - WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" - WIKI_PAGE="Macro_Multi_Export" - COMPONENT_NAME="Multi Export" - ;; *) echo "Unknown component: {{component}}" - echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)" + echo "Valid options: workbench (or bridge)" exit 1 ;; esac @@ -1302,17 +1001,9 @@ wiki-show component: WIKI_SOURCE="{{project_root}}/addon/FreecadRobustMCPBridge/wiki-source.txt" COMPONENT_NAME="Robust MCP Bridge Workbench" ;; - macro-magnets|magnets|cut) - WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" - COMPONENT_NAME="Cut Object for Magnets" - ;; - macro-export|export|multi) - WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" - COMPONENT_NAME="Multi Export" - ;; *) echo "Unknown component: {{component}}" - echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)" + echo "Valid options: workbench (or bridge)" exit 1 ;; esac @@ -1345,19 +1036,9 @@ wiki-diff component: WIKI_PAGE="Robust_MCP_Bridge_Workbench" COMPONENT_NAME="Robust MCP Bridge Workbench" ;; - macro-magnets|magnets|cut) - WIKI_SOURCE="{{project_root}}/macros/Cut_Object_for_Magnets/wiki-source.txt" - WIKI_PAGE="Macro_Cut_Object_for_Magnets" - COMPONENT_NAME="Cut Object for Magnets" - ;; - macro-export|export|multi) - WIKI_SOURCE="{{project_root}}/macros/Multi_Export/wiki-source.txt" - WIKI_PAGE="Macro_Multi_Export" - COMPONENT_NAME="Multi Export" - ;; *) echo "Unknown component: {{component}}" - echo "Valid options: workbench (or bridge), macro-magnets (or magnets, cut), macro-export (or export, multi)" + echo "Valid options: workbench (or bridge)" exit 1 ;; esac diff --git a/just/testing.just b/just/testing.just index c00fb3b..761bcfb 100644 --- a/just/testing.just +++ b/just/testing.just @@ -17,7 +17,7 @@ cov: uv run pytest tests/unit --cov=freecad_mcp --cov-report=term-missing --cov-report=html:htmlcov # Run tests without slow markers (excludes integration tests) -fast: +quick: uv run pytest {{project_root}}/tests/unit -m "not slow" # Run only integration tests (requires running FreeCAD Robust MCP Bridge) @@ -154,6 +154,16 @@ integration-freecad-auto: # Cleanup is handled by trap exit $TEST_EXIT_CODE +# ============================================================================= +# Test Setup +# ============================================================================= + +# Check if test dependencies are installed (fails if any are missing) +check-deps: + @uv run python -c "import pytest" 2>/dev/null && echo "✓ pytest installed" || { echo "✗ pytest not installed"; exit 1; } + @uv run python -c "import pytest_cov" 2>/dev/null && echo "✓ pytest-cov installed" || { echo "✗ pytest-cov not installed"; exit 1; } + @uv run python -c "import pytest_asyncio" 2>/dev/null && echo "✓ pytest-asyncio installed" || { echo "✗ pytest-asyncio not installed"; exit 1; } + # ============================================================================= # Just Command Tests # ============================================================================= @@ -314,8 +324,6 @@ release-test: echo " Edit the RELEASE_NOTES.md file for each component:" echo " - MCP Server: src/freecad_mcp/RELEASE_NOTES.md" echo " - Workbench: addon/FreecadRobustMCPBridge/RELEASE_NOTES.md" - echo " - Cut Macro: macros/Cut_Object_for_Magnets/RELEASE_NOTES.md" - echo " - Export Macro: macros/Multi_Export/RELEASE_NOTES.md" echo "" echo " Add a new version section at the top:" echo " ## Version X.Y.Z (YYYY-MM-DD)" @@ -329,11 +337,9 @@ release-test: echo " Tip: Use 'just release::draft-notes ' to generate draft notes" echo " from git commits since the last release." echo "" - echo "2. BUMP VERSIONS (for workbench/macros only - MCP server auto-bumps from tag):" + echo "2. BUMP VERSIONS (for workbench only - MCP server auto-bumps from tag):" echo "" echo " just release::bump-workbench " - echo " just release::bump-macro-magnets " - echo " just release::bump-macro-export " echo "" echo "3. COMMIT AND PUSH your RELEASE_NOTES.md and version bump changes" echo "" @@ -341,8 +347,6 @@ release-test: echo "" echo " just release::tag-mcp-server " echo " just release::tag-workbench " - echo " just release::tag-macro-magnets " - echo " just release::tag-macro-export " echo "" echo "NOTE: The MCP server version is determined by the git tag, so no bump needed." echo "NOTE: Release workflows extract notes from each component's RELEASE_NOTES.md." diff --git a/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro b/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro deleted file mode 100644 index faf6809..0000000 --- a/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro +++ /dev/null @@ -1,1931 +0,0 @@ -"""FreeCAD Macro: Cut Object for Magnets. - -SPDX-License-Identifier: MIT -Copyright (c) 2025 Sean P. Kane (GitHub: spkane) - -Cuts an object along a plane and adds connector holes for magnets with -surface collision detection. - -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" -""" - -# FreeCAD Addon Manager metadata -__Name__ = "Cut Object for Magnets" -__Comment__ = "Cut an object along a plane and add aligned magnet holes with surface collision detection" -__Author__ = "Sean P. Kane" -__Version__ = "0.6.1" -__Date__ = "2026-01-12" -__License__ = "MIT" -__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more" -__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme" -__Icon__ = "" -__Help__ = "Select an object to cut, run the macro, configure cut plane and magnet hole parameters, then click Execute Cut. Creates two parts with aligned magnet holes." -__Status__ = "Beta" -__Requires__ = "FreeCAD 0.19+" -__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues" -__Files__ = "" - -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, the inset point - # may land in the hole. Return the closest point on the face - # from the already-computed dist_info. - 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() - # Original object is hidden in execute() Transaction 8 - - 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 deleted file mode 100644 index b676950..0000000 --- a/macros/Cut_Object_for_Magnets/CutObjectForMagnets.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macros/Cut_Object_for_Magnets/CutObjectForMagnetsExample.png b/macros/Cut_Object_for_Magnets/CutObjectForMagnetsExample.png deleted file mode 100644 index ea2133e..0000000 Binary files a/macros/Cut_Object_for_Magnets/CutObjectForMagnetsExample.png and /dev/null differ diff --git a/macros/Cut_Object_for_Magnets/MacroCutObjectForMagnetsDialog.png b/macros/Cut_Object_for_Magnets/MacroCutObjectForMagnetsDialog.png deleted file mode 100644 index f163fa3..0000000 Binary files a/macros/Cut_Object_for_Magnets/MacroCutObjectForMagnetsDialog.png and /dev/null differ diff --git a/macros/Cut_Object_for_Magnets/Macro_Cut_Object_For_Magnets.png b/macros/Cut_Object_for_Magnets/Macro_Cut_Object_For_Magnets.png deleted file mode 100644 index f30b899..0000000 Binary files a/macros/Cut_Object_for_Magnets/Macro_Cut_Object_For_Magnets.png and /dev/null differ diff --git a/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md b/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md deleted file mode 100644 index 79126fe..0000000 --- a/macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md +++ /dev/null @@ -1,632 +0,0 @@ -# Cut Object for Magnets - FreeCAD Macro - -![Macro Icon](CutObjectForMagnets.svg) - -**Version:** 0.6.1 -**FreeCAD Version:** 0.19 or later -**License:** MIT - -> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/) - -## 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 - -### v0.5.0-beta (2026-01-05) - -- 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/Cut_Object_for_Magnets/RELEASE_NOTES.md b/macros/Cut_Object_for_Magnets/RELEASE_NOTES.md deleted file mode 100644 index c30096d..0000000 --- a/macros/Cut_Object_for_Magnets/RELEASE_NOTES.md +++ /dev/null @@ -1,42 +0,0 @@ -# Cut Object for Magnets Macro Release Notes - -## Version 0.6.1 (2026-01-12) - -Release notes for changes between v0.5.0-beta and v0.6.1. - -### Added - -- **FreeCAD Addon Manager metadata**: Macro now includes standard metadata fields for better Addon Manager integration -- **FreeCAD Wiki page**: Official wiki documentation at [Macro Cut Object for Magnets](https://wiki.freecad.org/Macro_Cut_Object_for_Magnets) -- **Example images**: Added screenshots showing the dialog and example output - -### Changed - -- **Version tracking**: Version now managed via `__Version__` metadata field instead of inline comment - -### Fixed - -- **Ring-shaped objects**: Improved handling of inset point calculation for hollow/ring-shaped cut faces where the inset point could land in the hole -- **Code formatting**: Minor cleanup for consistent code style - -### No Functional Changes - -The core cutting algorithm, magnet hole placement, and collision detection remain unchanged from the beta release. This macro is stable for production use. - -### Installation - -**Via FreeCAD Addon Manager:** - -1. Open FreeCAD -2. Go to Macro > Macros... -3. Click "Download" tab -4. Search for "Cut Object for Magnets" -5. Click Install - -**Manual Installation:** - -Copy `CutObjectForMagnets.FCMacro` to your FreeCAD macro directory: - -- **macOS**: `~/Library/Application Support/FreeCAD/Macro/` -- **Linux**: `~/.local/share/FreeCAD/Macro/` -- **Windows**: `%APPDATA%/FreeCAD/Macro/` diff --git a/macros/Cut_Object_for_Magnets/wiki-source.txt b/macros/Cut_Object_for_Magnets/wiki-source.txt deleted file mode 100644 index a0e6c72..0000000 --- a/macros/Cut_Object_for_Magnets/wiki-source.txt +++ /dev/null @@ -1,168 +0,0 @@ - - - - -{{Macro -|Name=Macro Cut Object for Magnets -|Icon=Macro_Cut_Object_For_Magnets.png -|Description=Cut an object along a plane and add aligned magnet holes with surface collision detection. Creates two parts with perfectly aligned holes for embedding magnets that allow the parts to snap together. -|Author=Sean P. Kane -|Version=0.6.1 -|Date=2026-01-12 -|FCVersion=0.19+ -|Download=[https://wiki.freecad.org/images/thumb/e/e3/Macro_Cut_Object_For_Magnets.png/48px-Macro_Cut_Object_For_Magnets.png ToolBar Icon] -|SeeAlso=[[Part_Slice|Part Slice]], [[PartDesign_Hole|PartDesign Hole]] -}} - -==Description== - - -This macro cuts a 3D object along a specified plane and automatically adds aligned magnet holes to both resulting pieces. The holes are positioned with intelligent collision detection to ensure they don't break through the outer walls of the object. - - -This is particularly useful for: -* Creating multi-part prints that snap together with embedded magnets -* Splitting large objects for smaller 3D printer beds while maintaining alignment -* Adding magnetic closure mechanisms to enclosures and cases - - -'''Key Features:''' -* Cut along preset planes (XY, XZ, YZ) or model datum planes -* Automatic hole placement with even distribution along the cut edge -* Surface collision detection prevents holes from breaking through walls -* Configurable hole diameter, depth, and count -* Preferred and minimum edge clearance settings -* Smart repositioning of holes when initial placement fails safety checks -* Creates PartDesign::Body objects with parametric Hole features -* Supports re-cutting already-cut objects (preserves existing holes) -* Automatically separates the two parts for easy viewing - -==Usage== - - -# Open a document with the object you want to cut -# Optionally select the object and/or a datum plane before running the macro -# Run the macro from '''Macro → Macros → CutObjectForMagnets → Execute''' -# In the dialog: -#* Select the body to cut from the dropdown -#* Choose the cut plane type (Preset or Model Plane) -#* For preset planes, set the offset from origin -#* Configure magnet hole parameters: -#** '''Diameter''': Hole diameter (should match your magnet size) -#** '''Depth''': Hole depth from cut surface -#** '''Number of Holes''': Total holes to create -#** '''Edge Clearance (Preferred)''': Ideal distance from hole edge to object surface -#** '''Edge Clearance (Minimum)''': Minimum acceptable clearance -# Click "Execute Cut" - -[[File:MacroCutObjectForMagnetsDialog.png|400px|center]] - - -==Example== - -This image shows a vase object in its original form, and after being cut multiple times with the macro. Magnets can then be inserted to object, and the object can be re-assembled. This example was to create a "breakable" vase for a theatrical stage performance, but there are many other potential uses as well. - -[[File:CutObjectForMagnetsExample.png|400px|center]] - -==How It Works== - - -'''Cutting Process:''' -# Creates a large half-space box aligned with the cutting plane -# Uses boolean operations to split the object into two parts -# Creates PartDesign::Body containers for each half - - -'''Hole Placement Algorithm:''' -# Calculates hole positions evenly distributed along the cut face perimeter -# For each position, validates against BOTH parts (not just one) -# Uses preferred clearance first, falls back to minimum if needed -# Checks for hole-to-hole spacing (minimum 2x diameter) -# Repositions holes that fail safety checks using multiple strategies: -#* Reduced clearance at same position -#* Increased inset from edge -#* Alternative positions along the perimeter - - -'''Safety Features:''' -* Creates test cylinders to check if holes would break through walls -* Validates each hole against both the top and bottom parts -* Skips holes that cannot be safely placed -* Reports repositioned and skipped holes in the console - -==Parameters== - - -{| class="wikitable" -! Parameter !! Description !! Default -|- -| Plane Type || "Preset Plane" (XY/XZ/YZ) or "Model Plane" (datum plane) || Preset Plane -|- -| Offset || Distance from origin for preset planes || 0 mm -|- -| Diameter || Magnet hole diameter || 3 mm -|- -| Depth || Hole depth from cut surface || 3 mm -|- -| Number of Holes || Total holes to create || 6 -|- -| Edge Clearance (Preferred) || Ideal distance from hole edge to object surface || 2 mm -|- -| Edge Clearance (Minimum) || Minimum acceptable clearance (used during repositioning) || 0.5 mm -|} - -==Requirements== - - -* FreeCAD 0.19 or later -* An object with a solid shape (Part or PartDesign body) -* For model plane mode: a datum plane or object with planar faces - -==Installation== - - -# Download the macro file: [[Media:CutObjectForMagnets.FCMacro|CutObjectForMagnets.FCMacro]] -# Copy the file to your FreeCAD macro directory: -#* '''macOS''': {{FileName|~/Library/Application Support/FreeCAD/Macro/}} -#* '''Linux''': {{FileName|~/.local/share/FreeCAD/Macro/}} -#* '''Windows''': {{FileName|%APPDATA%/FreeCAD/Macro/}} -# Optionally, download the toolbar icon and place it in the same directory - -==Tips== - - -* '''Magnet sizing''': Measure your magnets carefully. Common sizes are 3mm, 5mm, and 6mm diameter. -* '''Add tolerance''': Consider adding 0.1-0.2mm to the diameter for easier magnet insertion. -* '''Depth planning''': Set hole depth slightly deeper than magnet height to ensure flush or recessed fit. -* '''Edge clearance''': For thin-walled objects, reduce the preferred clearance but keep minimum clearance high enough to prevent wall breakthrough. -* '''Re-cutting''': The macro detects existing magnet holes and preserves them when cutting already-cut parts. - -==Source Code== - - -The full source code is hosted on GitHub: -* [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro CutObjectForMagnets.FCMacro on GitHub] - -==Script== - - -ToolBar Icon [[Image:Macro_Cut_Object_For_Magnets.png]] - - - -'''Macro_Cut_Object_For_Magnets.FCMacro''' - -- https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro - -==Links== - - -* [https://spkane.github.io/freecad-robust-mcp-and-more/ Full Documentation] - Complete guides and tutorials -* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository] - Source code and issue tracker -* [[Part_Slice|Part Slice]] - FreeCAD's built-in slice tool -* [[PartDesign_Hole|PartDesign Hole]] - Parametric hole feature documentation -* [[PartDesign_Body|PartDesign Body]] - Body container documentation - -[[Category:Macros{{#translation:}}]] -[[Category:User Documentation{{#translation:}}]] -[[Category:Addons {{#translation:}}]] diff --git a/macros/Multi_Export/MacroMultiExportDialog.png b/macros/Multi_Export/MacroMultiExportDialog.png deleted file mode 100644 index 1269601..0000000 Binary files a/macros/Multi_Export/MacroMultiExportDialog.png and /dev/null differ diff --git a/macros/Multi_Export/Macro_Multi_Export.png b/macros/Multi_Export/Macro_Multi_Export.png deleted file mode 100644 index 31f0207..0000000 Binary files a/macros/Multi_Export/Macro_Multi_Export.png and /dev/null differ diff --git a/macros/Multi_Export/MultiExport.FCMacro b/macros/Multi_Export/MultiExport.FCMacro deleted file mode 100644 index 26a62ce..0000000 --- a/macros/Multi_Export/MultiExport.FCMacro +++ /dev/null @@ -1,722 +0,0 @@ -"""FreeCAD Macro: Multi-Format Export. - -SPDX-License-Identifier: MIT -Copyright (c) 2025 Sean P. Kane (GitHub: spkane) - -Export selected bodies to multiple file formats simultaneously with a -user-friendly dialog for format selection and output configuration. - -Requirements: - - FreeCAD 0.21 or later - - One or more objects selected in the 3D view - -Usage: - 1. Select the object(s) to export - 2. Run the macro - 3. Select desired export formats (STL, STEP, 3MF selected by default) - 4. Choose output directory and base filename - 5. Click "Export" -""" - -# FreeCAD Addon Manager metadata -__Name__ = "Multi Export" -__Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously" -__Author__ = "Sean P. Kane" -__Version__ = "0.6.1" -__Date__ = "2026-01-12" -__License__ = "MIT" -__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more" -__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme" -__Icon__ = "" -__Help__ = "Select one or more objects, run the macro, choose export formats and output location, then click Export." -__Status__ = "Beta" -__Requires__ = "FreeCAD 0.21+" -__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues" -__Files__ = "" - -import os - -import FreeCAD as App -import FreeCADGui as Gui -import Mesh -import Part -from PySide import QtGui - - -class ExportFormat: - """Represents an export format with its properties.""" - - def __init__( - self, - name: str, - extension: str, - description: str, - default_enabled: bool = False, - ): - """Initialize an export format. - - Args: - name: Display name for the format - extension: File extension (without dot) - description: Brief description of the format - default_enabled: Whether this format is enabled by default - """ - self.name = name - self.extension = extension - self.description = description - self.default_enabled = default_enabled - - -# Define available export formats -EXPORT_FORMATS = [ - ExportFormat( - "STL", - "stl", - "Stereolithography - common for 3D printing", - default_enabled=True, - ), - ExportFormat( - "STEP", - "step", - "Standard for Exchange of Product Data - CAD interchange", - default_enabled=True, - ), - ExportFormat( - "3MF", - "3mf", - "3D Manufacturing Format - modern 3D printing format", - default_enabled=True, - ), - ExportFormat( - "OBJ", - "obj", - "Wavefront OBJ - 3D graphics and game engines", - default_enabled=False, - ), - ExportFormat( - "IGES", - "iges", - "Initial Graphics Exchange Specification - legacy CAD format", - default_enabled=False, - ), - ExportFormat( - "BREP", - "brep", - "OpenCASCADE native format - preserves exact geometry", - default_enabled=False, - ), - ExportFormat( - "PLY", - "ply", - "Polygon File Format - 3D scanning and printing", - default_enabled=False, - ), - ExportFormat( - "AMF", - "amf", - "Additive Manufacturing Format - XML-based 3D printing", - default_enabled=False, - ), -] - - -class MultiExportDialog(QtGui.QDialog): - """Dialog for configuring multi-format export options.""" - - def __init__(self, selected_objects: list, parent=None): - """Initialize the export dialog. - - Args: - selected_objects: List of FreeCAD objects to export - parent: Parent widget - """ - super(MultiExportDialog, self).__init__(parent) - self.selected_objects = selected_objects - self.format_checkboxes = {} - - self.setWindowTitle("Multi-Format Export") - self.setModal(True) - self.setMinimumWidth(500) - self.setup_ui() - self.populate_defaults() - - def setup_ui(self): - """Initialize the user interface.""" - layout = QtGui.QVBoxLayout() - - # Objects to export section - objects_group = QtGui.QGroupBox("Objects to Export") - objects_layout = QtGui.QVBoxLayout() - - self.objects_list = QtGui.QListWidget() - self.objects_list.setMaximumHeight(100) - self.objects_list.setSelectionMode(QtGui.QAbstractItemView.NoSelection) - for obj in self.selected_objects: - item = QtGui.QListWidgetItem(f"{obj.Label} ({_get_object_type(obj)})") - self.objects_list.addItem(item) - objects_layout.addWidget(self.objects_list) - - objects_group.setLayout(objects_layout) - layout.addWidget(objects_group) - - # Export formats section - formats_group = QtGui.QGroupBox("Export Formats") - formats_layout = QtGui.QGridLayout() - - for i, fmt in enumerate(EXPORT_FORMATS): - checkbox = QtGui.QCheckBox(f"{fmt.name} (.{fmt.extension})") - checkbox.setChecked(fmt.default_enabled) - checkbox.setToolTip(fmt.description) - self.format_checkboxes[fmt.extension] = checkbox - - row = i // 2 - col = i % 2 - formats_layout.addWidget(checkbox, row, col) - - # Quick selection buttons - button_row = (len(EXPORT_FORMATS) + 1) // 2 - select_all_btn = QtGui.QPushButton("Select All") - select_all_btn.clicked.connect(self._select_all_formats) - select_none_btn = QtGui.QPushButton("Select None") - select_none_btn.clicked.connect(self._select_no_formats) - select_defaults_btn = QtGui.QPushButton("Reset Defaults") - select_defaults_btn.clicked.connect(self._reset_default_formats) - - btn_layout = QtGui.QHBoxLayout() - btn_layout.addWidget(select_all_btn) - btn_layout.addWidget(select_none_btn) - btn_layout.addWidget(select_defaults_btn) - btn_layout.addStretch() - - formats_layout.addLayout(btn_layout, button_row, 0, 1, 2) - - formats_group.setLayout(formats_layout) - layout.addWidget(formats_group) - - # Output configuration section - output_group = QtGui.QGroupBox("Output Configuration") - output_layout = QtGui.QFormLayout() - - # Directory selection - dir_layout = QtGui.QHBoxLayout() - self.directory_edit = QtGui.QLineEdit() - self.directory_edit.setReadOnly(True) - browse_btn = QtGui.QPushButton("Browse...") - browse_btn.clicked.connect(self._browse_directory) - dir_layout.addWidget(self.directory_edit) - dir_layout.addWidget(browse_btn) - output_layout.addRow("Directory:", dir_layout) - - # Base filename - self.filename_edit = QtGui.QLineEdit() - self.filename_edit.setToolTip( - "Base filename without extension. Each format will be appended." - ) - output_layout.addRow("Base Filename:", self.filename_edit) - - # Preview of output files - self.preview_label = QtGui.QLabel("") - self.preview_label.setWordWrap(True) - self.preview_label.setStyleSheet("color: gray; font-size: 11px;") - output_layout.addRow("Will create:", self.preview_label) - - # Connect signals for preview updates - self.filename_edit.textChanged.connect(self._update_preview) - self.directory_edit.textChanged.connect(self._update_preview) - for checkbox in self.format_checkboxes.values(): - checkbox.stateChanged.connect(self._update_preview) - - output_group.setLayout(output_layout) - layout.addWidget(output_group) - - # Mesh options section (for STL, OBJ, PLY, 3MF, AMF) - mesh_group = QtGui.QGroupBox("Mesh Options (STL, OBJ, PLY, 3MF, AMF)") - mesh_layout = QtGui.QFormLayout() - - self.tolerance_spin = QtGui.QDoubleSpinBox() - self.tolerance_spin.setRange(0.001, 10.0) - self.tolerance_spin.setValue(0.1) - self.tolerance_spin.setDecimals(3) - self.tolerance_spin.setSuffix(" mm") - self.tolerance_spin.setToolTip( - "Lower values create finer meshes but larger files" - ) - mesh_layout.addRow("Surface Tolerance:", self.tolerance_spin) - - self.deflection_spin = QtGui.QDoubleSpinBox() - self.deflection_spin.setRange(0.001, 10.0) - self.deflection_spin.setValue(0.1) - self.deflection_spin.setDecimals(3) - self.deflection_spin.setSuffix(" mm") - self.deflection_spin.setToolTip("Angular deflection for curved surfaces") - mesh_layout.addRow("Angular Deflection:", self.deflection_spin) - - mesh_group.setLayout(mesh_layout) - layout.addWidget(mesh_group) - - # Status label - self.status_label = QtGui.QLabel("") - self.status_label.setWordWrap(True) - layout.addWidget(self.status_label) - - # Progress bar (hidden initially) - self.progress_bar = QtGui.QProgressBar() - self.progress_bar.setVisible(False) - layout.addWidget(self.progress_bar) - - # Buttons - button_box = QtGui.QDialogButtonBox() - self.export_btn = button_box.addButton( - "Export", 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_defaults(self): - """Populate default values based on the current document.""" - doc = App.ActiveDocument - - # Default directory: same as the FreeCAD document - if doc and doc.FileName: - default_dir = os.path.dirname(doc.FileName) - else: - default_dir = os.path.expanduser("~") - self.directory_edit.setText(default_dir) - - # Default filename: based on selected objects or document name - if len(self.selected_objects) == 1: - # Single object: use its label - default_name = _sanitize_filename(self.selected_objects[0].Label) - elif doc and doc.FileName: - # Multiple objects: use document name - doc_name = os.path.splitext(os.path.basename(doc.FileName))[0] - default_name = _sanitize_filename(doc_name) - elif doc: - default_name = _sanitize_filename(doc.Label) - else: - default_name = "export" - - self.filename_edit.setText(default_name) - self._update_preview() - - def _select_all_formats(self): - """Select all export formats.""" - for checkbox in self.format_checkboxes.values(): - checkbox.setChecked(True) - - def _select_no_formats(self): - """Deselect all export formats.""" - for checkbox in self.format_checkboxes.values(): - checkbox.setChecked(False) - - def _reset_default_formats(self): - """Reset to default format selection.""" - for fmt in EXPORT_FORMATS: - self.format_checkboxes[fmt.extension].setChecked(fmt.default_enabled) - - def _browse_directory(self): - """Open directory browser dialog.""" - current_dir = self.directory_edit.text() or os.path.expanduser("~") - directory = QtGui.QFileDialog.getExistingDirectory( - self, - "Select Export Directory", - current_dir, - QtGui.QFileDialog.ShowDirsOnly, - ) - if directory: - self.directory_edit.setText(directory) - - def _update_preview(self): - """Update the preview of files to be created.""" - selected_formats = self.get_selected_formats() - base_name = self.filename_edit.text().strip() - - if not selected_formats: - self.preview_label.setText("No formats selected") - return - - if not base_name: - self.preview_label.setText("Enter a filename") - return - - files = [f"{base_name}.{ext}" for ext in selected_formats] - if len(files) > 4: - preview_text = ", ".join(files[:4]) + f", ... (+{len(files) - 4} more)" - else: - preview_text = ", ".join(files) - - self.preview_label.setText(preview_text) - - def get_selected_formats(self) -> list[str]: - """Get list of selected format extensions.""" - return [ - ext - for ext, checkbox in self.format_checkboxes.items() - if checkbox.isChecked() - ] - - def get_export_parameters(self) -> dict: - """Get all export parameters from the dialog.""" - return { - "directory": self.directory_edit.text(), - "base_filename": self.filename_edit.text().strip(), - "formats": self.get_selected_formats(), - "mesh_tolerance": self.tolerance_spin.value(), - "mesh_deflection": self.deflection_spin.value(), - } - - 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) - QtGui.QApplication.processEvents() - - 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 MultiExporter: - """Handles exporting objects to multiple formats.""" - - def __init__(self, objects: list, params: dict): - """Initialize the exporter. - - Args: - objects: List of FreeCAD objects to export - params: Export parameters from the dialog - """ - self.objects = objects - self.params = params - self.exported_files = [] - self.errors = [] - - def export_all(self, progress_callback=None) -> tuple[list[str], list[str]]: - """Export objects to all selected formats. - - Args: - progress_callback: Optional callback for progress updates - - Returns: - Tuple of (list of exported files, list of errors) - """ - formats = self.params["formats"] - total_exports = len(formats) - - if total_exports == 0: - return [], ["No formats selected"] - - for i, fmt in enumerate(formats): - if progress_callback: - progress = int((i / total_exports) * 100) - progress_callback(progress, f"Exporting {fmt.upper()}...") - - try: - filepath = self._export_format(fmt) - self.exported_files.append(filepath) - App.Console.PrintMessage(f"Exported: {filepath}\n") - except Exception as e: - error_msg = f"Failed to export {fmt.upper()}: {e!s}" - self.errors.append(error_msg) - App.Console.PrintError(f"{error_msg}\n") - - if progress_callback: - progress_callback(100, "Export complete!") - - return self.exported_files, self.errors - - def _export_format(self, extension: str) -> str: - """Export to a specific format. - - Args: - extension: File extension (format identifier) - - Returns: - Path to the exported file - """ - directory = self.params["directory"] - base_name = self.params["base_filename"] - filepath = os.path.join(directory, f"{base_name}.{extension}") - - # Get the shape(s) to export - shapes = [] - for obj in self.objects: - if hasattr(obj, "Shape"): - shapes.append(obj.Shape) - elif hasattr(obj, "Mesh"): - shapes.append(obj.Mesh) - - if not shapes: - raise ValueError("No exportable shapes found") - - # Combine shapes if multiple - if len(shapes) == 1: - combined_shape = shapes[0] - else: - # For Part shapes, make a compound - combined_shape = Part.makeCompound(shapes) - - # Export based on format - if extension == "stl": - self._export_mesh(combined_shape, filepath, "stl") - elif extension == "step": - self._export_step(combined_shape, filepath) - elif extension == "3mf": - self._export_mesh(combined_shape, filepath, "3mf") - elif extension == "obj": - self._export_mesh(combined_shape, filepath, "obj") - elif extension == "iges": - self._export_iges(combined_shape, filepath) - elif extension == "brep": - self._export_brep(combined_shape, filepath) - elif extension == "ply": - self._export_mesh(combined_shape, filepath, "ply") - elif extension == "amf": - self._export_mesh(combined_shape, filepath, "amf") - else: - raise ValueError(f"Unsupported format: {extension}") - - return filepath - - def _export_mesh(self, shape, filepath: str, format_type: str): - """Export shape as a mesh format (STL, OBJ, PLY, 3MF, AMF). - - Args: - shape: Part.Shape to export - filepath: Output file path - format_type: Mesh format type - """ - tolerance = self.params["mesh_tolerance"] - deflection = self.params["mesh_deflection"] - - # Create mesh from shape - mesh = Mesh.Mesh() - - if hasattr(shape, "tessellate"): - # Part.Shape - tessellate it - vertices, facets = shape.tessellate(tolerance) - mesh_data = [] - for facet in facets: - triangle = [ - vertices[facet[0]], - vertices[facet[1]], - vertices[facet[2]], - ] - mesh_data.append(triangle) - mesh.addFacets(mesh_data) - elif hasattr(shape, "Facets"): - # Already a Mesh - mesh = shape - else: - raise ValueError("Cannot create mesh from object") - - # Export the mesh - mesh.write(filepath) - - def _export_step(self, shape, filepath: str): - """Export shape as STEP format. - - Args: - shape: Part.Shape to export - filepath: Output file path - """ - shape.exportStep(filepath) - - def _export_iges(self, shape, filepath: str): - """Export shape as IGES format. - - Args: - shape: Part.Shape to export - filepath: Output file path - """ - shape.exportIges(filepath) - - def _export_brep(self, shape, filepath: str): - """Export shape as BREP format. - - Args: - shape: Part.Shape to export - filepath: Output file path - """ - shape.exportBrep(filepath) - - -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 _sanitize_filename(name: str) -> str: - """Sanitize a string for use as a filename. - - Args: - name: The original name - - Returns: - Sanitized filename (without extension) - """ - # Replace problematic characters - invalid_chars = '<>:"/\\|?*' - result = name - for char in invalid_chars: - result = result.replace(char, "_") - - # Remove leading/trailing whitespace and dots - result = result.strip(" .") - - # Ensure non-empty - return result if result else "export" - - -def _get_exportable_objects(selection: list) -> list: - """Filter selection to only include exportable objects. - - Args: - selection: List of selected FreeCAD objects - - Returns: - List of objects that can be exported - """ - exportable = [] - for obj in selection: - # Check for Part shapes - if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"): - if obj.Shape.Volume > 0.001: - exportable.append(obj) - # Check for Mesh objects - elif hasattr(obj, "Mesh"): - exportable.append(obj) - return exportable - - -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 - selection = Gui.Selection.getSelection() - - if not selection: - QtGui.QMessageBox.warning( - None, - "No Selection", - "Please select one or more objects to export.\n\n" - "You can select multiple objects by holding Ctrl/Cmd while clicking.", - ) - return - - # Filter to exportable objects - exportable = _get_exportable_objects(selection) - - if not exportable: - QtGui.QMessageBox.warning( - None, - "No Exportable Objects", - "None of the selected objects can be exported.\n\n" - "Please select objects with solid shapes (Part or PartDesign bodies).", - ) - return - - # Show dialog - dialog = MultiExportDialog(exportable) - - if dialog.exec_() != QtGui.QDialog.Accepted: - return - - params = dialog.get_export_parameters() - - # Validate parameters - if not params["formats"]: - QtGui.QMessageBox.warning( - None, - "No Formats Selected", - "Please select at least one export format.", - ) - return - - if not params["base_filename"]: - QtGui.QMessageBox.warning( - None, - "No Filename", - "Please enter a base filename for the exports.", - ) - return - - if not os.path.isdir(params["directory"]): - QtGui.QMessageBox.warning( - None, - "Invalid Directory", - f"The directory does not exist:\n{params['directory']}", - ) - return - - # Perform export - try: - exporter = MultiExporter(exportable, params) - - def progress_update(value, message=""): - dialog.set_status(message) - dialog.set_progress(value) - - exported_files, errors = exporter.export_all(progress_update) - - # Show results - if exported_files: - success_msg = f"Successfully exported {len(exported_files)} file(s):\n" - for f in exported_files[:5]: - success_msg += f"\n • {os.path.basename(f)}" - if len(exported_files) > 5: - success_msg += f"\n ... and {len(exported_files) - 5} more" - - if errors: - success_msg += f"\n\nWarnings ({len(errors)}):\n" - for e in errors[:3]: - success_msg += f"\n • {e}" - - dialog.set_status(success_msg) - App.Console.PrintMessage( - f"Multi-export complete: {len(exported_files)} files\n" - ) - else: - error_msg = "Export failed:\n" + "\n".join(errors) - dialog.set_status(error_msg, is_error=True) - App.Console.PrintError(f"Multi-export failed: {errors}\n") - - except Exception as e: - dialog.set_status(f"Export error: {e!s}", is_error=True) - App.Console.PrintError(f"Multi-export error: {e!s}\n") - import traceback - - traceback.print_exc() - - -if __name__ == "__main__": - main() diff --git a/macros/Multi_Export/MultiExport.svg b/macros/Multi_Export/MultiExport.svg deleted file mode 100644 index 1cd7edd..0000000 --- a/macros/Multi_Export/MultiExport.svg +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - STL - - - - - - STEP - - - - - - 3MF - - - - - - - - - - - diff --git a/macros/Multi_Export/README-MultiExport.md b/macros/Multi_Export/README-MultiExport.md deleted file mode 100644 index 503316f..0000000 --- a/macros/Multi_Export/README-MultiExport.md +++ /dev/null @@ -1,298 +0,0 @@ -# Multi-Format Export - FreeCAD Macro - -![Macro Icon](MultiExport.svg) - -**Version:** 0.6.1 -**FreeCAD Version:** 0.19 or later -**License:** MIT - -> **Documentation:** [https://spkane.github.io/freecad-robust-mcp-and-more/](https://spkane.github.io/freecad-robust-mcp-and-more/) - -## Overview - -This FreeCAD macro exports selected objects to multiple file formats simultaneously with a single click. It features a user-friendly dialog for selecting export formats, configuring output options, and previewing the files that will be created. - -Perfect for: - -- Preparing files for 3D printing (STL, 3MF) -- CAD interchange with colleagues or clients (STEP, IGES) -- Asset creation for games or visualization (OBJ, PLY) -- Backup exports in multiple formats - -## Features - -- **Multi-Format Export** - Export to up to 8 different formats at once -- **Smart Defaults** - STL, STEP, and 3MF selected by default (most common formats) -- **Intelligent Path Defaults** - Uses document location and object names automatically -- **Mesh Quality Control** - Adjustable tolerance and deflection for mesh formats -- **Preview Mode** - See exactly what files will be created before exporting -- **Progress Feedback** - Real-time progress bar and status messages -- **Batch Export** - Export multiple selected objects together - ---- - -## Supported Formats - -| Format | Extension | Default | Description | -| ------ | --------- | ------- | ---------------------------------------- | -| STL | `.stl` | Yes | Stereolithography - standard 3D printing | -| STEP | `.step` | Yes | CAD interchange - preserves geometry | -| 3MF | `.3mf` | Yes | Modern 3D printing with metadata | -| OBJ | `.obj` | No | Wavefront - 3D graphics and games | -| IGES | `.iges` | No | Legacy CAD interchange | -| BREP | `.brep` | No | OpenCASCADE native - exact geometry | -| PLY | `.ply` | No | Polygon file - 3D scanning | -| AMF | `.amf` | No | Additive manufacturing format | - ---- - -## Installation - -### macOS - -1. Open **Finder** -1. Press `Cmd + Shift + G` (Go to Folder) -1. Paste: `~/Library/Application Support/FreeCAD/Macro/` -1. Press **Enter** -1. Copy `MultiExport.FCMacro` to this folder -1. (Optional) Copy `MultiExport.svg` for the icon - -**From 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 -1. Copy files there - -### Linux - -```bash -mkdir -p ~/.FreeCAD/Macro/ -cp MultiExport.FCMacro ~/.FreeCAD/Macro/ -cp MultiExport.svg ~/.FreeCAD/Macro/ # Optional icon -``` - -### Windows - -1. Navigate to: `%APPDATA%\FreeCAD\Macro\` -1. Copy `MultiExport.FCMacro` to this folder -1. (Optional) Copy `MultiExport.svg` to the same folder - ---- - -## How to Use - -### Quick Start - -1. **Select objects** in the 3D view (Ctrl+click for multiple) -1. **Run the macro:** Macro → Macros... → MultiExport → Execute -1. **Choose formats** (STL, STEP, 3MF are pre-selected) -1. **Set output location** and filename -1. **Click Export** - -### Detailed Steps - -#### Step 1: Select Objects - -- Click on one or more objects in the 3D view -- Hold `Ctrl` (or `Cmd` on macOS) to select multiple objects -- Selected objects appear in the "Objects to Export" list - -#### Step 2: Choose Export Formats - -The dialog shows all available formats with checkboxes: - -- **STL** - Checked by default (3D printing standard) -- **STEP** - Checked by default (CAD interchange) -- **3MF** - Checked by default (modern 3D printing) - -Quick selection buttons: - -- **Select All** - Check all formats -- **Select None** - Uncheck all formats -- **Reset Defaults** - Return to STL + STEP + 3MF - -#### Step 3: Configure Output - -**Directory:** - -- Defaults to the same folder as the current FreeCAD document -- Click **Browse...** to choose a different location - -**Base Filename:** - -- Defaults to the selected object's label (single object) -- Or the document name (multiple objects) -- Enter any name (without extension) -- Extensions are added automatically based on selected formats - -**Preview:** - -- Shows the files that will be created -- Updates in real-time as you change options - -#### Step 4: Mesh Options (Optional) - -For mesh formats (STL, OBJ, PLY, 3MF, AMF): - -**Surface Tolerance:** - -- Lower values = finer mesh = larger files -- Default: 0.1mm (good balance) -- For fine details: 0.01-0.05mm -- For quick exports: 0.2-0.5mm - -**Angular Deflection:** - -- Controls curved surface approximation -- Default: 0.1mm -- Lower values = smoother curves - -#### Step 5: Export - -- Click **Export** -- Watch the progress bar -- Review results in the status area - ---- - -## Parameter Reference - -### Output Configuration - -| Parameter | Default | Description | -| ------------- | ----------------- | ---------------------------- | -| Directory | Document location | Where to save exported files | -| Base Filename | Object/Doc label | Filename without extension | - -### Mesh Settings - -| Parameter | Default | Range | Description | -| ------------------ | ------- | ---------- | ----------------------------- | -| Surface Tolerance | 0.1 mm | 0.001-10mm | Mesh fineness (lower = finer) | -| Angular Deflection | 0.1 mm | 0.001-10mm | Curved surface approximation | - ---- - -## Use Cases - -### Case 1: 3D Printing Preparation - -**Scenario:** Export a model for printing on different slicers - -**Settings:** - -- Formats: STL + 3MF -- Tolerance: 0.1mm (standard quality) - -**Result:** Files ready for Cura, PrusaSlicer, Bambu Studio, etc. - -### Case 2: Sharing with CAD Users - -**Scenario:** Send design to colleague using SolidWorks - -**Settings:** - -- Formats: STEP + IGES -- (Mesh settings don't apply to these formats) - -**Result:** Editable CAD files that preserve exact geometry - -### Case 3: Game Asset Export - -**Scenario:** Create 3D model for Unity/Unreal - -**Settings:** - -- Formats: OBJ + PLY -- Tolerance: 0.05mm (good detail) - -**Result:** Standard mesh formats for game engines - -### Case 4: Complete Backup - -**Scenario:** Archive a project in all formats - -**Settings:** - -- Click **Select All** for all formats -- Directory: Your backup location - -**Result:** Complete export in 8 formats for future compatibility - ---- - -## Troubleshooting - -### Error: "No Selection" - -**Solution:** Select one or more objects in the 3D view before running the macro. - -### Error: "No Exportable Objects" - -**Cause:** Selected items are not solid objects (groups, annotations, etc.) - -**Solution:** Select actual Part or PartDesign bodies with solid geometry. - -### Error: "Invalid Directory" - -**Solution:** The chosen directory doesn't exist. Click Browse and select a valid folder. - -### Warning: Format export failed - -**Possible causes:** - -1. **Object has invalid geometry** - Try Part → Check Geometry first -1. **Disk full** - Check available disk space -1. **Permission denied** - Choose a folder you have write access to - ---- - -## Technical Details - -### Export Methods - -| Format | Export Method | -| ------ | -------------------------------- | -| STL | Mesh tessellation → Mesh.write() | -| STEP | Part.Shape.exportStep() | -| 3MF | Mesh tessellation → Mesh.write() | -| OBJ | Mesh tessellation → Mesh.write() | -| IGES | Part.Shape.exportIges() | -| BREP | Part.Shape.exportBrep() | -| PLY | Mesh tessellation → Mesh.write() | -| AMF | Mesh tessellation → Mesh.write() | - -### Multiple Objects - -When multiple objects are selected: - -- **Mesh formats:** Objects are combined into a single compound mesh -- **CAD formats:** Objects are combined into a Part.Compound - ---- - -## Version History - -### v0.5.0-beta (2026-01-05) - -- Initial release -- 8 export format support (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) -- Smart defaults (STL, STEP, 3MF pre-selected) -- Intelligent path and filename defaults -- Configurable mesh quality settings -- Real-time file preview -- Progress bar and status feedback -- Multi-object batch export - ---- - -## License - -MIT License - Free to use, modify, and distribute. - ---- - -## Credits - -Created to simplify the FreeCAD export workflow and eliminate repetitive export operations. diff --git a/macros/Multi_Export/RELEASE_NOTES.md b/macros/Multi_Export/RELEASE_NOTES.md deleted file mode 100644 index faed6e0..0000000 --- a/macros/Multi_Export/RELEASE_NOTES.md +++ /dev/null @@ -1,45 +0,0 @@ -# Multi Export Macro Release Notes - -## Version 0.6.1 (2026-01-12) - -Release notes for changes between v0.5.0-beta and v0.6.1. - -### Added - -- **FreeCAD Addon Manager metadata**: Macro now includes standard metadata fields for better Addon Manager integration -- **FreeCAD Wiki page**: Official wiki documentation at [Macro Multi Export](https://wiki.freecad.org/Macro_Multi_Export) -- **Dialog screenshot**: Added screenshot showing the export dialog - -### Changed - -- **Minimum FreeCAD version**: Updated requirement from FreeCAD 0.19 to FreeCAD 0.21 (for better 3MF support) -- **Version tracking**: Version now managed via `__Version__` metadata field instead of inline comment - -### No Functional Changes - -The core export functionality remains unchanged from the beta release: - -- Multi-format export (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) -- Configurable mesh tolerance for tessellated formats -- Batch export with progress feedback -- Format-specific options - -This macro is stable for production use. - -### Installation - -**Via FreeCAD Addon Manager:** - -1. Open FreeCAD -2. Go to Macro > Macros... -3. Click "Download" tab -4. Search for "Multi Export" -5. Click Install - -**Manual Installation:** - -Copy `MultiExport.FCMacro` to your FreeCAD macro directory: - -- **macOS**: `~/Library/Application Support/FreeCAD/Macro/` -- **Linux**: `~/.local/share/FreeCAD/Macro/` -- **Windows**: `%APPDATA%/FreeCAD/Macro/` diff --git a/macros/Multi_Export/wiki-source.txt b/macros/Multi_Export/wiki-source.txt deleted file mode 100644 index a304a37..0000000 --- a/macros/Multi_Export/wiki-source.txt +++ /dev/null @@ -1,101 +0,0 @@ - - - - -{{Macro -|Name=Macro Multi Export -|Icon=Macro_Multi_Export.png -|Description=Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with a user-friendly dialog for format selection and output configuration. -|Author=Sean P. Kane -|Version=0.6.1 -|Date=2026-01-12 -|FCVersion=0.21+ -|Download=[https://wiki.freecad.org/images/thumb/1/19/Macro_Multi_Export.png/48px-Macro_Multi_Export.png ToolBar Icon] -|SeeAlso=[[Std_Export|Std Export]], [[Import_Export|Import Export]] -}} - -==Description== - - -This macro provides a convenient way to export selected FreeCAD objects to multiple file formats at once. Instead of exporting to each format individually, you can select all desired formats in a single dialog and export them simultaneously. - - -'''Supported Export Formats:''' -* STL - Stereolithography (common for 3D printing) -* STEP - Standard for Exchange of Product Data (CAD interchange) -* 3MF - 3D Manufacturing Format (modern 3D printing format) -* OBJ - Wavefront OBJ (3D graphics and game engines) -* IGES - Initial Graphics Exchange Specification (legacy CAD format) -* BREP - OpenCASCADE native format (preserves exact geometry) -* PLY - Polygon File Format (3D scanning and printing) -* AMF - Additive Manufacturing Format (XML-based 3D printing) - - -'''Features:''' -* Export to multiple formats in a single operation -* User-friendly dialog for format selection -* Configurable output directory and base filename -* Mesh tolerance settings for STL/OBJ/PLY/3MF/AMF exports -* Preview of files to be created -* Quick select buttons: "Select All", "Select None", "Reset Defaults" -* Default formats: STL, STEP, and 3MF are pre-selected - -==Usage== - - -# Select one or more objects in the 3D view that you want to export -# Run the macro from '''Macro → Macros → MultiExport → Execute''' -# In the dialog: -#* Review the selected objects in the "Objects to Export" section -#* Check the formats you want to export to (STL, STEP, 3MF are selected by default) -#* Choose an output directory using the "Browse..." button -#* Enter a base filename (the format extension will be appended automatically) -#* Optionally adjust mesh tolerance settings for mesh-based formats -# Click "Export" to create the files - -[[File:Macro_Multi_Export_dialog.png|400px|center]] - -==Requirements== - - -* FreeCAD 0.21 or later -* Objects with solid shapes (Part or PartDesign bodies) - -==Installation== - - -# Download the macro file: [[Media:MultiExport.FCMacro|MultiExport.FCMacro]] -# Copy the file to your FreeCAD macro directory: -#* '''macOS''': {{FileName|~/Library/Application Support/FreeCAD/Macro/}} -#* '''Linux''': {{FileName|~/.local/share/FreeCAD/Macro/}} -#* '''Windows''': {{FileName|%APPDATA%/FreeCAD/Macro/}} -# Optionally, download the toolbar icon and place it in the same directory - -==Source Code== - - -The full source code is hosted on GitHub: -* [https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro MultiExport.FCMacro on GitHub] - -==Script== - - -ToolBar Icon [[Image:Macro_Multi_Export.png]] - - - -'''Macro_Multi_Export.FCMacro''' - -- https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/macros/Multi_Export/MultiExport.FCMacro - -==Links== - - -* [https://spkane.github.io/freecad-robust-mcp-and-more/ Full Documentation] - Complete guides and tutorials -* [https://github.com/spkane/freecad-robust-mcp-and-more GitHub Repository] - Source code and issue tracker -* [[Std_Export|Std Export]] - FreeCAD's built-in export function -* [[Import_Export|Import Export]] - Overview of FreeCAD import/export formats - -[[Category:Macros{{#translation:}}]] -[[Category:User Documentation{{#translation:}}]] -[[Category:Addons {{#translation:}}]] diff --git a/mkdocs.yaml b/mkdocs.yaml index 73b162a..ff3e6c5 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -1,8 +1,8 @@ site_name: FreeCAD Robust MCP Suite site_description: FreeCAD Robust MCP Suite - MCP Server, Bridge Workbench, and Macros for AI assistant integration -site_url: https://github.com/spkane/freecad-robust-mcp-and-more -repo_url: https://github.com/spkane/freecad-robust-mcp-and-more -repo_name: spkane/freecad-robust-mcp-and-more +site_url: https://github.com/spkane/freecad-addon-robust-mcp-server +repo_url: https://github.com/spkane/freecad-addon-robust-mcp-server +repo_name: spkane/freecad-addon-robust-mcp-server theme: name: material @@ -112,7 +112,7 @@ extra: default: latest social: - icon: fontawesome/brands/github - link: https://github.com/spkane/freecad-robust-mcp-and-more + link: https://github.com/spkane/freecad-addon-robust-mcp-server - icon: fontawesome/brands/docker link: https://hub.docker.com/r/spkane/freecad-robust-mcp - icon: fontawesome/brands/python @@ -127,7 +127,7 @@ nav: - User Guide: - Connection Modes: guide/connection-modes.md - Robust MCP Bridge Workbench: guide/workbench.md - - FreeCAD Macros: guide/macros.md + - MCP Macro Tools: guide/macros.md - Tools Overview: guide/tools.md - MCP Resources: guide/resources.md - Detailed User Guide: USER_GUIDE.md diff --git a/package.xml b/package.xml index 4753f79..78ac298 100644 --- a/package.xml +++ b/package.xml @@ -1,8 +1,8 @@ - FreeCAD Robust MCP Suite - A collection of FreeCAD macros plus a robust MCP (Model Context Protocol) bridge workbench for AI assistant integration. + FreeCAD Robust MCP Bridge + A robust MCP (Model Context Protocol) bridge workbench for AI assistant integration with FreeCAD. addon/FreecadRobustMCPBridge/FreecadRobustMCPBridge.svg @@ -15,16 +15,12 @@ 0.21 - https://github.com/spkane/freecad-robust-mcp-and-more - https://github.com/spkane/freecad-robust-mcp-and-more/issues - https://github.com/spkane/freecad-robust-mcp-and-more/blob/main/README.md - https://github.com/spkane/freecad-robust-mcp-and-more#readme + https://github.com/spkane/freecad-addon-robust-mcp-server + https://github.com/spkane/freecad-addon-robust-mcp-server/issues + https://github.com/spkane/freecad-addon-robust-mcp-server/blob/main/README.md + https://github.com/spkane/freecad-addon-robust-mcp-server#readme workbench - macro - export - 3D printing - magnets MCP AI automation @@ -46,26 +42,6 @@ 0.21 - - Multi Export - 0.6.1 - 2026-01-12 - Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously with configurable mesh options. - ./macros/Multi_Export/ - MultiExport.FCMacro - MultiExport.svg - - - - Cut Object for Magnets - 0.6.1 - 2026-01-12 - Cut an object along a plane and add aligned magnet holes with surface collision detection. Perfect for creating 3D printed parts that snap together with embedded magnets. - ./macros/Cut_Object_for_Magnets/ - CutObjectForMagnets.FCMacro - CutObjectForMagnets.svg - - diff --git a/pyproject.toml b/pyproject.toml index cdb1597..5920bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -335,7 +335,5 @@ major_version_zero = true # Note: This project uses component-specific tags for releases: # - robust-mcp-server-vX.Y.Z (MCP server: PyPI, Docker, GitHub release) # - robust-mcp-workbench-vX.Y.Z (FreeCAD workbench) -# - macro-cut-object-for-magnets-vX.Y.Z -# - macro-multi-export-vX.Y.Z # Use `just release::tag- ` to create release tags. # Commitizen is used for commit message validation and changelog generation. diff --git a/tests/integration/test_cut_object_for_magnets.py b/tests/integration/test_cut_object_for_magnets.py deleted file mode 100644 index c9df748..0000000 --- a/tests/integration/test_cut_object_for_magnets.py +++ /dev/null @@ -1,1037 +0,0 @@ -"""Integration tests for the CutObjectForMagnets macro. - -These tests verify the SmartCutter class functionality including: -- Cutting solid objects with boolean hole operations -- Cutting hollow objects with boolean hole operations -- Boolean hole creation method (primary method for CI compatibility) -- Edge cases and error handling - -Test Organization: -- TestCutSolidObject: Tests cutting solid objects with boolean holes -- TestCutHollowObject: Tests cutting hollow objects with boolean holes -- TestBooleanHoleFallback: Tests for the boolean hole creation method -- TestEdgeCases: Edge case tests (single hole, many holes) - -Note: These tests require a running FreeCAD Robust MCP Bridge. - Start it with: just freecad::run-gui or just freecad::run-headless - -Note: PartDesign::Hole has a CADKernelError bug in some FreeCAD headless - environments (especially AppImage on Linux CI) where it fails with - "Cannot make face from profile". These tests use boolean holes instead, - which work reliably in both GUI and headless mode. - -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, use_boolean=True): - """Execute the complete cutting and hole placement operation. - - Args: - progress_callback: Optional callback for progress updates. - use_boolean: If True, use boolean holes (CI-compatible). - If False, use PartDesign::Hole (may fail in some headless envs). - """ - 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") - - if use_boolean: - # Use boolean holes (works reliably in headless mode) - bottom_with_holes = self._create_holes_boolean(bottom_shape, -normal, validated_positions) - top_with_holes = self._create_holes_boolean(top_shape, normal, validated_positions) - - # Create Part::Feature objects for results - doc = App.ActiveDocument - bottom_obj = doc.addObject("Part::Feature", f"{self.obj.Label}_Bottom") - bottom_obj.Shape = bottom_with_holes - top_obj = doc.addObject("Part::Feature", f"{self.obj.Label}_Top") - top_obj.Shape = top_with_holes - doc.recompute() - - return bottom_obj, top_obj - else: - # Use PartDesign::Hole (may fail with CADKernelError in some headless envs) - # 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 (alternative method). - - This is an alternative to PartDesign::Hole that uses Part boolean - operations. Both methods work in GUI and headless mode. - """ - 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 TestCutSolidObject: - """Tests for cutting solid objects with boolean hole operations. - - These tests work in both GUI and 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 "CutMacroTestDoc" in FreeCAD.listDocuments(): - FreeCAD.closeDocument("CutMacroTestDoc") -doc = FreeCAD.newDocument("CutMacroTestDoc") -_result_ = True -""", - ) - - def test_cut_solid_box_with_boolean_holes( - self, xmlrpc_proxy: xmlrpc.client.ServerProxy - ) -> None: - """Test cutting a solid box and creating boolean holes.""" - 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() - -# Calculate original volume for comparison -original_volume = box.Volume - -# 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 with boolean holes (use_boolean=True is the default) -bottom_obj, top_obj = cutter.execute() - -doc.recompute() - -# Calculate expected hole volume -import math -hole_volume = math.pi * (params["diameter"] / 2) ** 2 * params["depth"] - -# Verify results -_result_ = { - "success": True, - "bottom_type": bottom_obj.TypeId, - "top_type": top_obj.TypeId, - "bottom_name": bottom_obj.Label, - "top_name": top_obj.Label, - "bottom_volume": bottom_obj.Shape.Volume, - "top_volume": top_obj.Shape.Volume, - "bottom_valid": bottom_obj.Shape.isValid(), - "top_valid": top_obj.Shape.isValid(), - "original_volume": original_volume, - "hole_volume_each": hole_volume, - # Combined volume should be less than original due to holes - "total_volume": bottom_obj.Shape.Volume + top_obj.Shape.Volume, -} - -# Volume should have decreased from original (holes were cut) -_result_["volume_decreased"] = _result_["total_volume"] < original_volume -""", - ) - - assert result["result"]["success"] is True - # Boolean method produces Part::Feature objects - assert result["result"]["bottom_type"] == "Part::Feature" - assert result["result"]["top_type"] == "Part::Feature" - assert "Bottom" in result["result"]["bottom_name"] - assert "Top" in result["result"]["top_name"] - assert result["result"]["bottom_valid"] is True - assert result["result"]["top_valid"] is True - # Volumes should be positive - assert result["result"]["bottom_volume"] > 0 - assert result["result"]["top_volume"] > 0 - # Volume should have decreased due to holes - assert result["result"]["volume_decreased"] is True - - -class TestCutHollowObject: - """Tests for cutting hollow objects with boolean hole operations. - - These tests work in both GUI and 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 "CutHollowTestDoc" in FreeCAD.listDocuments(): - FreeCAD.closeDocument("CutHollowTestDoc") -doc = FreeCAD.newDocument("CutHollowTestDoc") -_result_ = True -""", - ) - - def test_cut_hollow_cylinder_with_boolean_holes( - self, xmlrpc_proxy: xmlrpc.client.ServerProxy - ) -> None: - """Test cutting a hollow cylinder (vase shape) with boolean holes.""" - 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() - -# Calculate original volume -original_volume = vase_shape.Volume - -# 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 with boolean holes -bottom_obj, top_obj = cutter.execute() - -doc.recompute() - -# Verify results -_result_ = { - "success": True, - "bottom_type": bottom_obj.TypeId, - "top_type": top_obj.TypeId, - "bottom_valid": bottom_obj.Shape.isValid(), - "top_valid": top_obj.Shape.isValid(), - "bottom_volume": bottom_obj.Shape.Volume, - "top_volume": top_obj.Shape.Volume, - "original_volume": original_volume, - "total_volume": bottom_obj.Shape.Volume + top_obj.Shape.Volume, -} - -# Volume should have decreased from original (holes were cut) -_result_["volume_decreased"] = _result_["total_volume"] < original_volume -""", - ) - - assert result["result"]["success"] is True - # Boolean method produces Part::Feature objects - assert result["result"]["bottom_type"] == "Part::Feature" - assert result["result"]["top_type"] == "Part::Feature" - assert result["result"]["bottom_valid"] is True - assert result["result"]["top_valid"] is True - # Volumes should be positive - assert result["result"]["bottom_volume"] > 0 - assert result["result"]["top_volume"] > 0 - # Volume should have decreased due to holes - assert result["result"]["volume_decreased"] is True - - -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_method_consistency( - self, xmlrpc_proxy: xmlrpc.client.ServerProxy - ) -> None: - """Verify boolean hole method produces consistent results on identical objects.""" - 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, -} - -# Run 1: Boolean method on box1 -cutter1 = SmartCutter(box1_obj, params) -result1_bottom, result1_top = cutter1.execute() # use_boolean=True is default - -# Run 2: Boolean method on box2 (identical operation) -cutter2 = SmartCutter(box2_obj, params) -result2_bottom, result2_top = cutter2.execute() - -doc.recompute() - -# Compare results - should be identical for same inputs -vol1_bottom = result1_bottom.Shape.Volume -vol1_top = result1_top.Shape.Volume -vol2_bottom = result2_bottom.Shape.Volume -vol2_top = result2_top.Shape.Volume - -_result_ = { - "success": True, - "run1_bottom_volume": vol1_bottom, - "run1_top_volume": vol1_top, - "run2_bottom_volume": vol2_bottom, - "run2_top_volume": vol2_top, - "run1_bottom_type": result1_bottom.TypeId, - "run2_bottom_type": result2_bottom.TypeId, - # Volumes should be identical for same inputs - "volumes_match": abs(vol1_bottom - vol2_bottom) < 0.01 and abs(vol1_top - vol2_top) < 0.01, - "run1_bottom_valid": result1_bottom.Shape.isValid(), - "run1_top_valid": result1_top.Shape.isValid(), - "run2_bottom_valid": result2_bottom.Shape.isValid(), - "run2_top_valid": result2_top.Shape.isValid(), -} -""", - ) - - assert result["result"]["success"] is True - # Both runs should produce valid shapes - assert result["result"]["run1_bottom_valid"] is True - assert result["result"]["run1_top_valid"] is True - assert result["result"]["run2_bottom_valid"] is True - assert result["result"]["run2_top_valid"] is True - # Boolean method produces Part::Feature objects - assert result["result"]["run1_bottom_type"] == "Part::Feature" - assert result["result"]["run2_bottom_type"] == "Part::Feature" - # Volumes should be identical for same inputs - assert result["result"]["volumes_match"] is True - - -class TestEdgeCases: - """Tests for edge cases and error handling. - - These tests work in both GUI and 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 "EdgeCaseTestDoc" in FreeCAD.listDocuments(): - FreeCAD.closeDocument("EdgeCaseTestDoc") -doc = FreeCAD.newDocument("EdgeCaseTestDoc") -_result_ = True -""", - ) - - def test_small_hole_count(self, xmlrpc_proxy: xmlrpc.client.ServerProxy) -> None: - """Test with a small number of holes on a larger box for reliable placement.""" - result = execute_code( - xmlrpc_proxy, - SMART_CUTTER_CODE - + """ -import math - -doc = App.ActiveDocument - -# Use a larger box for better hole placement with small hole counts -box = Part.makeBox(80, 80, 40) -box_obj = doc.addObject("Part::Feature", "TestBox") -box_obj.Shape = box -doc.recompute() - -original_volume = box.Volume - -params = { - "plane_type": "Preset Plane", - "plane": "XY", - "offset": 20.0, - "diameter": 4.0, # Smaller holes - "depth": 3.0, - "hole_count": 4, # 4 holes on 80mm box = good spacing - "clearance_preferred": 3.0, # Increased clearance - "clearance_min": 1.0, -} - -cutter = SmartCutter(box_obj, params) -bottom_obj, top_obj = cutter.execute() - -doc.recompute() - -# Calculate expected hole volume for verification -hole_volume = math.pi * (params["diameter"] / 2) ** 2 * params["depth"] - -_result_ = { - "success": True, - "bottom_valid": bottom_obj.Shape.isValid(), - "top_valid": top_obj.Shape.isValid(), - "bottom_volume": bottom_obj.Shape.Volume, - "top_volume": top_obj.Shape.Volume, - "original_volume": original_volume, - "total_volume": bottom_obj.Shape.Volume + top_obj.Shape.Volume, - "hole_volume_each": hole_volume, -} - -# Volume should have decreased from original (holes were cut) -_result_["volume_decreased"] = _result_["total_volume"] < original_volume -""", - ) - - assert result["result"]["success"] is True - assert result["result"]["bottom_valid"] is True - assert result["result"]["top_valid"] is True - # Volume should have decreased due to holes - assert result["result"]["volume_decreased"] is True - - 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 - + """ -import math - -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() - -original_volume = box.Volume - -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_obj, top_obj = cutter.execute() - -doc.recompute() - -# Calculate expected hole volume for verification -hole_volume = math.pi * (params["diameter"] / 2) ** 2 * params["depth"] - -_result_ = { - "success": True, - "bottom_valid": bottom_obj.Shape.isValid(), - "top_valid": top_obj.Shape.isValid(), - "bottom_volume": bottom_obj.Shape.Volume, - "top_volume": top_obj.Shape.Volume, - "original_volume": original_volume, - "total_volume": bottom_obj.Shape.Volume + top_obj.Shape.Volume, - "hole_volume_each": hole_volume, -} - -# Volume should have decreased from original (holes were cut) -_result_["volume_decreased"] = _result_["total_volume"] < original_volume -""", - ) - - assert result["result"]["success"] is True - assert result["result"]["bottom_valid"] is True - assert result["result"]["top_valid"] is True - # Volume should have decreased due to holes - assert result["result"]["volume_decreased"] is True diff --git a/tests/integration/test_multi_export.py b/tests/integration/test_multi_export.py deleted file mode 100644 index 75ad31e..0000000 --- a/tests/integration/test_multi_export.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Integration tests for the MultiExport macro. - -These tests verify the MultiExporter class functionality including: -- Exporting to STL format -- Exporting to STEP format -- Exporting to multiple formats simultaneously -- Handling mesh tolerance settings - -Note: These tests require a running FreeCAD Robust MCP Bridge. - Start it with: just freecad::run-gui or just freecad::run-headless - -To run these tests: - pytest tests/integration/test_multi_export.py -v -""" - -from __future__ import annotations - -import os -import tempfile -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 - - -def execute_code(proxy: xmlrpc.client.ServerProxy, code: str) -> dict[str, Any]: - """Execute Python code via the MCP bridge and return the result.""" - result = proxy.execute(code) # type: ignore[union-attr] - assert isinstance(result, dict), f"Unexpected result type: {type(result)}" - assert result.get("success"), f"Execution failed: {result.get('error_traceback')}" - return result - - -# The MultiExporter class code embedded for testing -MULTI_EXPORTER_CODE = ''' -import os -import FreeCAD as App -import Part -import Mesh - - -class MultiExporter: - """Handles exporting objects to multiple formats.""" - - def __init__(self, objects, params): - """Initialize the exporter.""" - self.objects = objects - self.params = params - self.exported_files = [] - self.errors = [] - - def export_all(self): - """Export objects to all selected formats.""" - formats = self.params["formats"] - - if not formats: - return [], ["No formats selected"] - - for fmt in formats: - try: - filepath = self._export_format(fmt) - self.exported_files.append(filepath) - except Exception as e: - self.errors.append(f"Failed to export {fmt.upper()}: {str(e)}") - - return self.exported_files, self.errors - - def _export_format(self, extension): - """Export to a specific format.""" - directory = self.params["directory"] - base_name = self.params["base_filename"] - filepath = os.path.join(directory, f"{base_name}.{extension}") - - shapes = [] - for obj in self.objects: - if hasattr(obj, "Shape"): - shapes.append(obj.Shape) - - if not shapes: - raise ValueError("No exportable shapes found") - - if len(shapes) == 1: - combined_shape = shapes[0] - else: - combined_shape = Part.makeCompound(shapes) - - if extension == "stl": - self._export_mesh(combined_shape, filepath) - elif extension == "step": - combined_shape.exportStep(filepath) - elif extension == "brep": - combined_shape.exportBrep(filepath) - else: - raise ValueError(f"Unsupported format: {extension}") - - return filepath - - def _export_mesh(self, shape, filepath): - """Export shape as mesh format.""" - tolerance = self.params.get("mesh_tolerance", 0.1) - mesh = Mesh.Mesh() - vertices, facets = shape.tessellate(tolerance) - mesh_data = [] - for facet in facets: - triangle = [vertices[facet[0]], vertices[facet[1]], vertices[facet[2]]] - mesh_data.append(triangle) - mesh.addFacets(mesh_data) - mesh.write(filepath) -''' - - -@pytest.fixture -def temp_export_dir(): - """Create a temporary directory for export tests.""" - with tempfile.TemporaryDirectory() as tmpdir: - yield tmpdir - - -class TestMultiExporter: - """Tests for the MultiExporter functionality.""" - - def test_export_stl(self, xmlrpc_proxy, temp_export_dir): - """Test exporting a simple box to STL format.""" - code = f""" -{MULTI_EXPORTER_CODE} - -# Create a new document and simple box -doc = App.newDocument("TestExportSTL") -box = doc.addObject("Part::Box", "TestBox") -box.Length = 20 -box.Width = 20 -box.Height = 10 -doc.recompute() - -# Set up export parameters -params = {{ - "directory": "{temp_export_dir}", - "base_filename": "test_box", - "formats": ["stl"], - "mesh_tolerance": 0.1, -}} - -# Create exporter and export -exporter = MultiExporter([box], params) -exported_files, errors = exporter.export_all() - -# Clean up -App.closeDocument("TestExportSTL") - -_result_ = {{ - "exported_files": exported_files, - "errors": errors, - "file_exists": os.path.exists(exported_files[0]) if exported_files else False, -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - assert len(data["exported_files"]) == 1 - assert len(data["errors"]) == 0 - assert data["file_exists"] is True - assert data["exported_files"][0].endswith(".stl") - - def test_export_step(self, xmlrpc_proxy, temp_export_dir): - """Test exporting a simple cylinder to STEP format.""" - code = f""" -{MULTI_EXPORTER_CODE} - -# Create a new document and simple cylinder -doc = App.newDocument("TestExportSTEP") -cylinder = doc.addObject("Part::Cylinder", "TestCylinder") -cylinder.Radius = 10 -cylinder.Height = 30 -doc.recompute() - -# Set up export parameters -params = {{ - "directory": "{temp_export_dir}", - "base_filename": "test_cylinder", - "formats": ["step"], - "mesh_tolerance": 0.1, -}} - -# Create exporter and export -exporter = MultiExporter([cylinder], params) -exported_files, errors = exporter.export_all() - -# Clean up -App.closeDocument("TestExportSTEP") - -_result_ = {{ - "exported_files": exported_files, - "errors": errors, - "file_exists": os.path.exists(exported_files[0]) if exported_files else False, -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - assert len(data["exported_files"]) == 1 - assert len(data["errors"]) == 0 - assert data["file_exists"] is True - assert data["exported_files"][0].endswith(".step") - - def test_export_multiple_formats(self, xmlrpc_proxy, temp_export_dir): - """Test exporting to multiple formats simultaneously.""" - code = f""" -{MULTI_EXPORTER_CODE} - -# Create a new document and simple sphere -doc = App.newDocument("TestExportMulti") -sphere = doc.addObject("Part::Sphere", "TestSphere") -sphere.Radius = 15 -doc.recompute() - -# Set up export parameters for multiple formats -params = {{ - "directory": "{temp_export_dir}", - "base_filename": "test_sphere", - "formats": ["stl", "step", "brep"], - "mesh_tolerance": 0.1, -}} - -# Create exporter and export -exporter = MultiExporter([sphere], params) -exported_files, errors = exporter.export_all() - -# Check which files exist -files_exist = [os.path.exists(f) for f in exported_files] - -# Clean up -App.closeDocument("TestExportMulti") - -_result_ = {{ - "exported_files": exported_files, - "errors": errors, - "files_exist": files_exist, - "count": len(exported_files), -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - assert data["count"] == 3 - assert len(data["errors"]) == 0 - assert all(data["files_exist"]) - # Verify each format is present - extensions = [os.path.splitext(f)[1] for f in data["exported_files"]] - assert ".stl" in extensions - assert ".step" in extensions - assert ".brep" in extensions - - def test_export_multiple_objects(self, xmlrpc_proxy, temp_export_dir): - """Test exporting multiple objects as a compound.""" - code = f""" -{MULTI_EXPORTER_CODE} - -# Create a new document with multiple objects -doc = App.newDocument("TestExportMultiObj") -box = doc.addObject("Part::Box", "Box1") -box.Length = 10 -box.Width = 10 -box.Height = 10 - -cylinder = doc.addObject("Part::Cylinder", "Cyl1") -cylinder.Radius = 5 -cylinder.Height = 20 -cylinder.Placement.Base = App.Vector(20, 0, 0) - -doc.recompute() - -# Set up export parameters -params = {{ - "directory": "{temp_export_dir}", - "base_filename": "test_compound", - "formats": ["stl"], - "mesh_tolerance": 0.1, -}} - -# Create exporter with multiple objects -exporter = MultiExporter([box, cylinder], params) -exported_files, errors = exporter.export_all() - -# Check file size (compound should be larger than single object) -file_size = os.path.getsize(exported_files[0]) if exported_files else 0 - -# Clean up -App.closeDocument("TestExportMultiObj") - -_result_ = {{ - "exported_files": exported_files, - "errors": errors, - "file_exists": os.path.exists(exported_files[0]) if exported_files else False, - "file_size": file_size, -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - assert len(data["exported_files"]) == 1 - assert len(data["errors"]) == 0 - assert data["file_exists"] is True - # Compound file should have some reasonable size - assert data["file_size"] > 100 - - def test_export_with_custom_tolerance(self, xmlrpc_proxy, temp_export_dir): - """Test that mesh tolerance affects output file size. - - Note: FreeCAD's tessellate function has internal limits, so we need - to use a very fine tolerance (0.01) to actually see more triangles - compared to the default tessellation. - """ - code = f""" -{MULTI_EXPORTER_CODE} - -# Create a sphere (curved surface shows tolerance effect best) -doc = App.newDocument("TestTolerance") -sphere = doc.addObject("Part::Sphere", "TestSphere") -sphere.Radius = 20 -doc.recompute() - -# Export with coarse tolerance (uses default tessellation) -params_coarse = {{ - "directory": "{temp_export_dir}", - "base_filename": "sphere_coarse", - "formats": ["stl"], - "mesh_tolerance": 1.0, -}} -exporter_coarse = MultiExporter([sphere], params_coarse) -coarse_files, _ = exporter_coarse.export_all() -coarse_size = os.path.getsize(coarse_files[0]) if coarse_files else 0 - -# Export with very fine tolerance (0.01 required to see difference) -params_fine = {{ - "directory": "{temp_export_dir}", - "base_filename": "sphere_fine", - "formats": ["stl"], - "mesh_tolerance": 0.01, -}} -exporter_fine = MultiExporter([sphere], params_fine) -fine_files, _ = exporter_fine.export_all() -fine_size = os.path.getsize(fine_files[0]) if fine_files else 0 - -# Clean up -App.closeDocument("TestTolerance") - -_result_ = {{ - "coarse_size": coarse_size, - "fine_size": fine_size, - "fine_is_larger": fine_size > coarse_size, -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - # Fine tolerance (0.01) should produce larger file (more triangles) - assert data["fine_is_larger"] is True - assert data["fine_size"] > data["coarse_size"] - - def test_export_empty_formats_list(self, xmlrpc_proxy, temp_export_dir): - """Test that empty formats list returns appropriate error.""" - code = f""" -{MULTI_EXPORTER_CODE} - -doc = App.newDocument("TestEmptyFormats") -box = doc.addObject("Part::Box", "TestBox") -doc.recompute() - -params = {{ - "directory": "{temp_export_dir}", - "base_filename": "test", - "formats": [], - "mesh_tolerance": 0.1, -}} - -exporter = MultiExporter([box], params) -exported_files, errors = exporter.export_all() - -App.closeDocument("TestEmptyFormats") - -_result_ = {{ - "exported_files": exported_files, - "errors": errors, -}} -""" - result = execute_code(xmlrpc_proxy, code) - data = result.get("result", {}) - - assert len(data["exported_files"]) == 0 - assert len(data["errors"]) == 1 - assert "No formats selected" in data["errors"][0] diff --git a/tests/just_commands/test_install.py b/tests/just_commands/test_install.py index c4cb569..d172880 100644 --- a/tests/just_commands/test_install.py +++ b/tests/just_commands/test_install.py @@ -23,13 +23,9 @@ class TestInstallSyntax: "install::uninstall-mcp-server", "install::mcp-bridge-workbench", "install::uninstall-mcp-bridge-workbench", - "install::macro-cut", - "install::uninstall-macro-cut", - "install::macro-export", - "install::uninstall-macro-export", - "install::macro-all", - "install::uninstall-macro-all", "install::status", + "install::uninstall", + "install::cleanup", ] @pytest.mark.just_syntax @@ -77,3 +73,23 @@ class TestInstallRuntime: assert uninstall_result.success, ( f"MCP server uninstall failed: {uninstall_result.stderr}" ) + + @pytest.mark.just_runtime + def test_uninstall_runs(self, just: JustRunner) -> None: + """Uninstall command should run without error. + + This command uninstalls all components. It may complete with warnings + if nothing is installed, but should not error. + """ + result = just.run("install::uninstall", timeout=120) + # Command should succeed even if nothing was installed + assert result.success, f"Uninstall failed: {result.stderr}" + + @pytest.mark.just_runtime + def test_cleanup_runs(self, just: JustRunner) -> None: + """Cleanup command should run without error. + + This command cleans up caches and temporary files. + """ + result = just.run("install::cleanup", timeout=60) + assert result.success, f"Cleanup failed: {result.stderr}" diff --git a/tests/just_commands/test_listing.py b/tests/just_commands/test_listing.py index 6a29646..fc9c73e 100644 --- a/tests/just_commands/test_listing.py +++ b/tests/just_commands/test_listing.py @@ -63,11 +63,17 @@ class TestModulesExist: # Map of modules to their expected commands (subset for validation) EXPECTED_COMMANDS: ClassVar[dict[str, list[str]]] = { "quality": ["check", "format", "lint", "typecheck", "security"], - "testing": ["unit", "cov", "fast", "integration"], + "testing": ["unit", "cov", "quick", "integration"], "dev": ["install-deps", "install-pre-commit", "clean"], "docker": ["build", "run", "clean"], "documentation": ["build", "serve", "open"], - "install": ["mcp-server", "mcp-bridge-workbench", "status"], + "install": [ + "mcp-server", + "mcp-bridge-workbench", + "status", + "uninstall", + "cleanup", + ], "mcp": ["run", "check"], "freecad": ["run-gui", "run-headless"], "release": ["status", "list-tags", "latest-versions"], @@ -110,7 +116,7 @@ class TestSyntaxValidation: # Testing commands "testing::unit", "testing::cov", - "testing::fast", + "testing::quick", "testing::verbose", # Dev commands "dev::install-deps", @@ -131,8 +137,6 @@ class TestSyntaxValidation: # Install commands "install::mcp-server", "install::mcp-bridge-workbench", - "install::macro-cut", - "install::macro-export", "install::status", # MCP commands "mcp::check", diff --git a/tests/just_commands/test_release.py b/tests/just_commands/test_release.py index 2a3299e..314041f 100644 --- a/tests/just_commands/test_release.py +++ b/tests/just_commands/test_release.py @@ -44,13 +44,9 @@ class TestReleaseSyntax: RELEASE_COMMANDS: ClassVar[list[tuple[str, list[str]]]] = [ # Version bump commands ("bump-workbench", ["0.0.1-test"]), - ("bump-macro-magnets", ["0.0.1-test"]), - ("bump-macro-export", ["0.0.1-test"]), # Tag commands (require version argument) ("tag-mcp-server", ["0.0.1-test"]), ("tag-workbench", ["0.0.1-test"]), - ("tag-macro-magnets", ["0.0.1-test"]), - ("tag-macro-export", ["0.0.1-test"]), # Info commands ("list-tags", []), ("latest-versions", []), @@ -63,9 +59,9 @@ class TestReleaseSyntax: # Tag management ("delete-tag", ["test-tag-v0.0.1"]), # Wiki commands - ("wiki-update", ["magnets"]), - ("wiki-show", ["magnets"]), - ("wiki-diff", ["magnets"]), + ("wiki-update", ["workbench"]), + ("wiki-show", ["workbench"]), + ("wiki-diff", ["workbench"]), ] @pytest.mark.just_syntax @@ -106,7 +102,7 @@ class TestReleaseReadOnly: @pytest.mark.just_runtime @pytest.mark.parametrize( "component", - ["mcp-server", "workbench", "macro-magnets", "macro-export"], + ["mcp-server", "workbench"], ) def test_changes_since_works(self, just: JustRunner, component: str) -> None: """changes-since should work for each component.""" @@ -121,7 +117,7 @@ class TestReleaseReadOnly: @pytest.mark.just_runtime @pytest.mark.parametrize( "component", - ["mcp-server", "workbench", "macro-magnets", "macro-export"], + ["mcp-server", "workbench"], ) def test_draft_notes_works(self, just: JustRunner, component: str) -> None: """draft-notes should work for each component.""" @@ -135,8 +131,6 @@ class TestReleaseReadOnly: [ ("mcp-server", "1.0.0"), ("workbench", "1.0.0"), - ("macro-magnets", "1.0.0"), - ("macro-export", "1.0.0"), ], ) def test_dry_run_tag_shows_info( @@ -148,10 +142,9 @@ class TestReleaseReadOnly: assert "Would create tag" in result.stdout @pytest.mark.just_runtime - @pytest.mark.parametrize("macro", ["magnets", "export"]) - def test_wiki_show_works(self, just: JustRunner, macro: str) -> None: + def test_wiki_show_works(self, just: JustRunner) -> None: """wiki-show should display wiki source content.""" - result = just.run("release::wiki-show", macro, timeout=10) + result = just.run("release::wiki-show", "workbench", timeout=10) assert result.success, f"wiki-show failed: {result.stderr}" assert "Wiki Source" in result.stdout @@ -175,15 +168,6 @@ class TestReleaseBumpCommands: / "addon/FreecadRobustMCPBridge/freecad_mcp_bridge/__init__.py", PROJECT_ROOT / "addon/FreecadRobustMCPBridge/wiki-source.txt", PROJECT_ROOT / "package.xml", - # Cut Object for Magnets macro files - PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro", - PROJECT_ROOT - / "macros/Cut_Object_for_Magnets/README-CutObjectForMagnets.md", - PROJECT_ROOT / "macros/Cut_Object_for_Magnets/wiki-source.txt", - # Multi Export macro files - PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro", - PROJECT_ROOT / "macros/Multi_Export/README-MultiExport.md", - PROJECT_ROOT / "macros/Multi_Export/wiki-source.txt", ] backups: dict[Path, str] = {} @@ -214,38 +198,6 @@ class TestReleaseBumpCommands: content = init_file.read_text() assert "99.99.99-test" in content - @pytest.mark.just_runtime - @pytest.mark.just_release - def test_bump_macro_magnets_modifies_files( - self, just: JustRunner, backup_and_restore_files: None - ) -> None: - """bump-macro-magnets should modify version files.""" - result = just.run("release::bump-macro-magnets", "99.99.99-test", timeout=30) - assert result.success, f"bump-macro-magnets failed: {result.stderr}" - assert "Version bump complete" in result.stdout - - # Verify version was updated - macro_file = ( - PROJECT_ROOT / "macros/Cut_Object_for_Magnets/CutObjectForMagnets.FCMacro" - ) - content = macro_file.read_text() - assert "99.99.99-test" in content - - @pytest.mark.just_runtime - @pytest.mark.just_release - def test_bump_macro_export_modifies_files( - self, just: JustRunner, backup_and_restore_files: None - ) -> None: - """bump-macro-export should modify version files.""" - result = just.run("release::bump-macro-export", "99.99.99-test", timeout=30) - assert result.success, f"bump-macro-export failed: {result.stderr}" - assert "Version bump complete" in result.stdout - - # Verify version was updated - macro_file = PROJECT_ROOT / "macros/Multi_Export/MultiExport.FCMacro" - content = macro_file.read_text() - assert "99.99.99-test" in content - class TestReleaseTagCommands: """Tests for tag creation commands. @@ -347,10 +299,6 @@ class TestReleaseValidation: "mcp-server", "server", "workbench", - "macro-magnets", - "magnets", - "macro-export", - "export", ], ) def test_changes_since_component_aliases( diff --git a/tests/just_commands/test_testing.py b/tests/just_commands/test_testing.py index c8fa863..4e37d4b 100644 --- a/tests/just_commands/test_testing.py +++ b/tests/just_commands/test_testing.py @@ -22,11 +22,12 @@ class TestTestingSyntax: TESTING_COMMANDS: ClassVar[list[str]] = [ "testing::unit", "testing::cov", - "testing::fast", + "testing::quick", "testing::integration", "testing::verbose", "testing::all", "testing::watch", + "testing::check-deps", "testing::integration-freecad-auto", "testing::just-syntax", "testing::just-runtime", @@ -73,11 +74,11 @@ class TestTestingRuntime: assert_command_executed(result, "testing::unit") @pytest.mark.just_runtime - def test_fast_command_recognizes_markers(self, just: JustRunner) -> None: - """Fast test command should recognize the 'not slow' marker.""" + def test_quick_command_recognizes_markers(self, just: JustRunner) -> None: + """Quick test command should recognize the 'not slow' marker.""" result = just.run( - "testing::fast", + "testing::quick", timeout=60, env={"PYTEST_ADDOPTS": "--collect-only -q"}, ) - assert_command_executed(result, "testing::fast") + assert_command_executed(result, "testing::quick") diff --git a/tests/unit/test_resources.py b/tests/unit/test_resources.py index bb175b3..12f9e78 100644 --- a/tests/unit/test_resources.py +++ b/tests/unit/test_resources.py @@ -373,9 +373,9 @@ class TestFreecadResources: """freecad://macros should return macro list.""" mock_macros = [ MacroInfo( - name="MultiExport", - path="/home/user/.local/share/FreeCAD/Macro/MultiExport.FCMacro", - description="Export to multiple formats", + name="ExportSTL", + path="/home/user/.local/share/FreeCAD/Macro/ExportSTL.FCMacro", + description="Export objects to STL", is_system=False, ), MacroInfo( @@ -392,7 +392,7 @@ class TestFreecadResources: data = json.loads(result) assert len(data) == 2 - assert data[0]["name"] == "MultiExport" + assert data[0]["name"] == "ExportSTL" assert data[0]["is_system"] is False assert data[1]["is_system"] is True diff --git a/tests/unit/test_tools_macros.py b/tests/unit/test_tools_macros.py index c0982f1..67d2b9f 100644 --- a/tests/unit/test_tools_macros.py +++ b/tests/unit/test_tools_macros.py @@ -58,9 +58,9 @@ class TestMacroTools: """list_macros should return macro info.""" mock_macros = [ MacroInfo( - name="MultiExport", - path="/home/user/.FreeCAD/Macro/MultiExport.FCMacro", - description="Export to multiple formats", + name="ExportSTL", + path="/home/user/.FreeCAD/Macro/ExportSTL.FCMacro", + description="Export objects to STL", is_system=False, ), MacroInfo( @@ -76,7 +76,7 @@ class TestMacroTools: result = await list_macros() assert len(result) == 2 - assert result[0]["name"] == "MultiExport" + assert result[0]["name"] == "ExportSTL" assert result[0]["is_system"] is False assert result[1]["name"] == "SystemMacro" assert result[1]["is_system"] is True @@ -96,11 +96,11 @@ class TestMacroTools: ) run_macro = register_tools["run_macro"] - result = await run_macro(macro_name="MultiExport") + result = await run_macro(macro_name="ExportSTL") assert result["success"] is True assert result["stdout"] == "Exported 3 objects\n" - mock_bridge.run_macro.assert_called_once_with("MultiExport", None) + mock_bridge.run_macro.assert_called_once_with("ExportSTL", None) @pytest.mark.asyncio async def test_run_macro_with_args(self, register_tools, mock_bridge):