diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9e88778..1ce8f6a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,23 +24,14 @@ jobs: uses: actions/checkout@v2 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: '3.10' - name: Install Python dependencies - # grab the currently pinned black version from CQ. The `curl | grep` - # result will be an empty string if black is not pinned. If black is - # not pinned in CQ, final command will reduce to - # `pip install black flake8`. + # CadQuery runs a customized version of black run: | - echo "grabbing black version from CQ" - black_ver=$(curl "https://raw.githubusercontent.com/CadQuery/cadquery/master/environment.yml" | grep -oP '(?<=black=).*') - echo "got: $black_ver" - if [[ -n "$black_ver" ]]; then - black_ver="==$black_ver"; - fi - pip install black$black_ver flake8 click==8.0.4 + pip install git+https://github.com/cadquery/black.git@cq # Runs the lint check against the repo - name: Run black diff --git a/.github/workflows/tests-actions.yml b/.github/workflows/tests-actions.yml index d00829d..4482184 100644 --- a/.github/workflows/tests-actions.yml +++ b/.github/workflows/tests-actions.yml @@ -24,16 +24,16 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: "latest" - python-version: 3.8 - activate-environment: test + python-version: "3.10" + activate-environment: "freecad" # Installs CadQuery and pytest so that the test can be run - name: Install CadQuery and pytest shell: bash --login {0} run: | + conda install conda-forge::freecad conda install -c cadquery -c conda-forge cadquery=master conda install -c anaconda pytest @@ -48,14 +48,14 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: "latest" - python-version: 3.8 - activate-environment: test + python-version: "3.10" + activate-environment: "freecad" - name: Install CadQuery and pytest shell: bash --login {0} run: | + conda install conda-forge::freecad conda install -c cadquery -c conda-forge cadquery=master conda install -c anaconda pytest - name: Run tests @@ -68,14 +68,17 @@ jobs: runs-on: "windows-latest" steps: - uses: actions/checkout@v2 - - uses: conda-incubator/setup-miniconda@v2 + - uses: conda-incubator/setup-miniconda@v3 with: - miniconda-version: "latest" - python-version: 3.8 - activate-environment: test + mamba-version: "*" + channels: conda-forge,defaults + channel-priority: true + python-version: "3.10" + activate-environment: "freecad" - name: Install CadQuery and pytest shell: pwsh run: | + conda install conda-forge::freecad conda install -c cadquery -c conda-forge cadquery=master conda install -c anaconda pytest - name: Run tests diff --git a/.gitignore b/.gitignore index 662c070..a2397fa 100644 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,6 @@ dmypy.json #vscode .vscode/ - +# Unwanted FreeCAD files +*.FCBak +updated_part.FCStd diff --git a/plugins/freecad_import/README.md b/plugins/freecad_import/README.md new file mode 100644 index 0000000..daa3555 --- /dev/null +++ b/plugins/freecad_import/README.md @@ -0,0 +1,55 @@ +# FreeCAD Importer Plugin + +This plugin allows users to import FreeCAD models into CadQuery, and will apply parameters to the model if they are provided and the model is a parametric one (contains a FreeCAD spreadsheet document). At this time this plugin does not handle FreeCAD assemblies. + +## Installation + +Something like an Anaconda environment with FreeCAD installed as a conda package may be required on some Linux distros like Ubuntu because of the requirement to use the FreeCAD Snap to get the latest version of FreeCAD. The Snap does not seem to allow FreeCAD to be imported properly in Python. If you do use Anaconda, name the environment `freecad` to help this plugin find it. See the [documentation here](https://cadquery.readthedocs.io/en/latest/installation.html#install-the-conda-package-manager) for instructions on how to set up Anaconda without messing up your local Python installation. A example conda installation to get this plugin working is shown below. +```bash +mamba create -n freecad python=3.10 +mamba install -c cadquery cadquery=master +mamba install freecad:freecad +``` + +Assuming that you have pip and git installed, the following line can be used to install this plugin. + +``` +pip install -e "git+https://github.com/CadQuery/cadquery-plugins.git#egg=freecad_importer&subdirectory=plugins/freecad_importer" +``` + +## Dependencies + +FreeCAD must be installed and importable via Python in order for this plugin to work. CadQuery is also required. To install CadQuery, follow the [instructions here](https://cadquery.readthedocs.io/en/latest/installation.html), or use the conda instructions above. + +## Usage + +To use this plugin after it has been installed, import it to automatically patch its functions into the `cadquery.importers` package. + +Here is an example of importing a parametric FreeCAD part. + +```python +import cadquery as cq +# The below adds the plugin's functions to cadquery.importers +from freecad_importer import import_freecad_part + +# Imports a FreeCAD part while altering its parameters. +# The parameter must exist for the part or an errorr will be thrown. +result = import_freecad_part( + "path_to_freecad_part_file.FCStd", parameters={"mount_dia": {"value": 4.8, "units": "mm"}} + ) +``` + +Here is an example of retrieving the parameters from a parametric FreeCAD part. + +```python +import cadquery as cq +# The below adds the plugin's functions to cadquery.importers +from freecad_importer import get_freecad_part_parameters + +# Get the parameters from the objectr +params = get_freecad_part_parameters( + "path_to_freecad_part_file.FCStd", name_column_letter="A", value_column_letter="B" +) +``` + +The tests associated with this plugin have additional code that also might be useful to review as examples. diff --git a/plugins/freecad_import/__init__.py b/plugins/freecad_import/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/freecad_import/freecad_importer.py b/plugins/freecad_import/freecad_importer.py new file mode 100644 index 0000000..9965fd8 --- /dev/null +++ b/plugins/freecad_import/freecad_importer.py @@ -0,0 +1,311 @@ +import os, sys +import glob +import zipfile +import tempfile +import cadquery as cq + + +def _fc_path(): + """ + Pulled from cadquery-freecad-module. + Used to find the FreeCAD installation so that it can be imported in Python. + Parameters: + None + Returns: + Path to any FreeCAD installation that was found. + """ + + # Look for FREECAD_LIB env variable + _PATH = os.environ.get("FREECAD_LIB", "") + if _PATH and os.path.exists(_PATH): + return _PATH + + # Try to guess if using Anaconda + if "env" in sys.prefix: + if sys.platform.startswith("linux") or sys.platform.startswith("darwin"): + _PATH = os.path.join(sys.prefix, "lib") + # return PATH if FreeCAD.[so,pyd] is present + if len(glob.glob(os.path.join(_PATH, "FreeCAD.so"))) > 0: + return _PATH + elif sys.platform.startswith("win"): + _PATH = os.path.join(sys.prefix, "Library", "bin") + # return PATH if FreeCAD.[so,pyd] is present + if len(glob.glob(os.path.join(_PATH, "FreeCAD.pyd"))) > 0: + return _PATH + + if sys.platform.startswith("linux"): + home_dir = os.environ["HOME"] + + # Make some dangerous assumptions... + for _PATH in [ + os.path.join(os.path.expanduser("~"), "lib/freecad/lib"), + "/usr/local/lib/freecad/lib", + "/usr/lib/freecad/lib", + "/opt/freecad/lib/", + "/usr/bin/freecad/lib", + "/usr/lib/freecad-daily/lib", + "/usr/lib/freecad", + "/usr/lib64/freecad/lib", + str(home_dir) + "/mambaforge/envs/freecad/lib/", + str(home_dir) + "/miniforge/envs/freecad/lib/", + str(home_dir) + "/conda/envs/freecad/lib/", + str(home_dir) + "/condaforge/envs/freecad/lib/", + ]: + if os.path.exists(_PATH): + return _PATH + + elif sys.platform.startswith("win"): + # Try all the usual suspects + for _PATH in [ + "c:/Program Files/FreeCAD0.12/bin", + "c:/Program Files/FreeCAD0.13/bin", + "c:/Program Files/FreeCAD0.14/bin", + "c:/Program Files/FreeCAD0.15/bin", + "c:/Program Files/FreeCAD0.16/bin", + "c:/Program Files/FreeCAD0.17/bin", + "c:/Program Files (x86)/FreeCAD0.12/bin", + "c:/Program Files (x86)/FreeCAD0.13/bin", + "c:/Program Files (x86)/FreeCAD0.14/bin", + "c:/Program Files (x86)/FreeCAD0.15/bin", + "c:/Program Files (x86)/FreeCAD0.16/bin", + "c:/Program Files (x86)/FreeCAD0.17/bin", + "c:/apps/FreeCAD0.12/bin", + "c:/apps/FreeCAD0.13/bin", + "c:/apps/FreeCAD0.14/bin", + "c:/apps/FreeCAD0.15/bin", + "c:/apps/FreeCAD0.16/bin", + "c:/apps/FreeCAD0.17/bin", + "c:/Program Files/FreeCAD 0.12/bin", + "c:/Program Files/FreeCAD 0.13/bin", + "c:/Program Files/FreeCAD 0.14/bin", + "c:/Program Files/FreeCAD 0.15/bin", + "c:/Program Files/FreeCAD 0.16/bin", + "c:/Program Files/FreeCAD 0.17/bin", + "c:/Program Files (x86)/FreeCAD 0.12/bin", + "c:/Program Files (x86)/FreeCAD 0.13/bin", + "c:/Program Files (x86)/FreeCAD 0.14/bin", + "c:/Program Files (x86)/FreeCAD 0.15/bin", + "c:/Program Files (x86)/FreeCAD 0.16/bin", + "c:/Program Files (x86)/FreeCAD 0.17/bin", + "c:/apps/FreeCAD 0.12/bin", + "c:/apps/FreeCAD 0.13/bin", + "c:/apps/FreeCAD 0.14/bin", + "c:/apps/FreeCAD 0.15/bin", + "c:/apps/FreeCAD 0.16/bin", + "c:/apps/FreeCAD 0.17/bin", + "C:/Miniconda/envs/freecad/bin", + ]: + if os.path.exists(_PATH): + return _PATH + + elif sys.platform.startswith("darwin"): + # Assume we're dealing with a Mac + for _PATH in [ + "/Applications/FreeCAD.app/Contents/lib", + "/Applications/FreeCAD.app/Contents/Resources/lib", + os.path.join( + os.path.expanduser("~"), "Library/Application Support/FreeCAD/lib" + ), + ]: + if os.path.exists(_PATH): + return _PATH + + raise ImportError("Unable to determine freecad library path") + + +def import_part_static(fc_part_path): + """ + Imports without parameter handling by extracting the brep file from the FCStd file. + Does NOT require FreeCAD to be installed. + Parameters: + fc_part_path - Path to the FCStd file to be imported. + Returns: + A CadQuery Workplane object or None if the import was unsuccessful. + """ + + res = None + + # Make sure that the caller gave a valid file path + if not os.path.isfile(fc_part_path): + print("Please specify a valid path.") + return None + + # A temporary directory is required to extract the zipped files to + with tempfile.TemporaryDirectory() as temp_dir: + # Extract the contents of the file + with zipfile.ZipFile(fc_part_path, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + # Open the file with CadQuery + res = cq.Workplane(cq.Shape.importBrep(os.path.join(temp_dir, "PartShape.brp"))) + + return res + + +def import_part_parametric(fc_part_path, parameters=None): + """ + Uses FreeCAD to import a part and update it with altered parameters. + Requires FreeCAD to be installed and importable in Python. + Parameters: + fc_part_path - Path to the FCStd file to be imported. + parameters - Model parameters that should be used to modify the part. + Returns: + A CadQuery Workplane object or None if the import was unsuccessful. + """ + + # If the caller did not specify any parameters, might as well call the static importer + if parameters == None: + return import_part_static(fc_part_path) + + try: + # Attempt to include the FreeCAD installation path + path = _fc_path() + sys.path.insert(0, path) + + # It should be possible to import FreeCAD now + import FreeCAD + except Exception as err: + print( + "FreeCAD must be installed, and it must be possible to import it in Python." + ) + return None + + # Open the part file in FreeCAD and get the spreadsheet so we can update it + doc = FreeCAD.openDocument(fc_part_path) + + # Get a reference to the the spreadsheet + sheet = doc.getObject("Spreadsheet") + + # Update each matching item in the spreadsheet + for key in parameters.keys(): + sheet.set(key, "=" + str(parameters[key]["value"]) + parameters[key]["units"]) + sheet.recompute() + + # We need to touch each model to have it update + for object in doc.Objects: + object.touch() + + # Make sure the 3D object is updated + FreeCAD.ActiveDocument.recompute() + + # We use the local directory for now because FreeCAD does not seem to want to open files from the /tmp directory + updated_path = "updated_part.FCStd" + + # Save the document and then re-open it as a static part + doc.saveAs(updated_path) + FreeCAD.ActiveDocument.saveAs(updated_path) + + # Re-import the model statically + res = import_part_static(updated_path) + + # Close the open document + FreeCAD.closeDocument(doc.Name) + + return res + + +def import_freecad_part(fc_part_path, parameters=None): + """ + Wrapper method that chooses whether or not to do a static import based on whether + or not parameters are passed. + Parameters: + fc_part_path - Path to the FCStd file to be imported. + parameters - Model parameters that should be used to modify the part. + Returns: + A CadQuery Workplane object or None if the import was unsuccessful. + """ + + res = None + + # If there are no parameters specified, we can do a static import + if parameters == None: + res = import_part_static(fc_part_path) + else: + res = import_part_parametric(fc_part_path, parameters) + + return res + + +def get_freecad_part_parameters(fc_part_path, name_column_letter, value_column_letter): + """ + Extracts the parameters from the spreadsheet inside the FCStd file. + Does NOT require FreeCAD to be installed. + Parameters: + fc_part_path - Path to the FCStd file to be imported. + name_column_letter - Allows the caller to specify the column of the spreadsheet where the parameter name can be found. + value_column_letter - Allows the caller to specify the column of the spreadsheet where the parameter value can be found. + Returns: + A dictionary of the parameters, their initial values and the units of the values. + """ + + # Make sure that the caller gave a valid file path + if not os.path.isfile(fc_part_path): + print("Please specify a valid path.") + return None + + # This will keep the collection of the parameters and their current values + parameters = {} + + # To split units from values + import re + + # So that the XML file can be parsed + import xml.etree.ElementTree as ET + + # A temporary directory is required to extract the zipped files to + with tempfile.TemporaryDirectory() as temp_dir: + # Extract the contents of the file + with zipfile.ZipFile(fc_part_path, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + # parse the Document.xml file that holds metadata like the spreadsheet + tree = ET.parse(os.path.join(temp_dir, "Document.xml")) + root = tree.getroot() + objects = root.find("ObjectData") + for object in objects.iter("Object"): + if object.get("name") == "Spreadsheet": + props = object.find("Properties") + for prop in props.iter("Property"): + if prop.get("name") == "cells": + for cell in prop.find("Cells").iter(): + if cell is None or cell.get("content") is None: + continue + + # Determine whether we have a parameter name or a parameter value + if "=" not in cell.get("content"): + # Make sure we did not get a description + if ( + cell.get("address")[0] != name_column_letter + and cell.get("address")[0] != value_column_letter + ): + continue + + # Start a parameter entry in the dictionary + parameters[cell.get("content")] = {} + elif "=" in cell.get("content"): + # Extract the units + units = "".join( + re.findall("[a-zA-Z]+", cell.get("content")) + ) + if units is not None: + parameters[cell.get("alias")]["units"] = units + else: + parameters[cell.get("alias")]["units"] = "N/A" + + # Extract the parameter value and store it + value = ( + cell.get("content") + .replace("=", "") + .replace(units, "") + ) + parameters[cell.get("alias")]["value"] = value + break + else: + continue + + return parameters + + +# Patch the FreeCAD import functions into CadQuery's importers package +cq.importers.import_freecad_part = import_freecad_part +cq.importers.get_freecad_part_parameters = get_freecad_part_parameters diff --git a/plugins/freecad_import/setup.py b/plugins/freecad_import/setup.py new file mode 100644 index 0000000..538c515 --- /dev/null +++ b/plugins/freecad_import/setup.py @@ -0,0 +1,47 @@ +from setuptools import setup, find_packages + +# Change these variables to set the information for your plugin +version = "1.0.0" +plugin_name = "freecad_import" # The name of your plugin +description = "Adds FreeCAD part import support to CadQuery" +long_description = "Allows import the BRep objects from inside FCStd files, and supports altering their parameters before doing so." +author = "Jeremy Wright" +author_email = "@jmwright" +packages = [] +py_modules = ["freecad_importer"] +install_requires = [] + + +setup( + name=plugin_name, + version=version, + url="https://github.com/CadQuery/cadquery-plugins", + license="Apache Public License 2.0", + author=author, + author_email=author_email, + description=description, + long_description=long_description, + packages=packages, + py_modules=py_modules, + install_requires=install_requires, + include_package_data=True, + zip_safe=False, + platforms="any", + test_suite="tests", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: Unix", + "Programming Language :: Python", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet", + "Topic :: Scientific/Engineering", + ], +) diff --git a/tests/test_freecad_importer.py b/tests/test_freecad_importer.py new file mode 100644 index 0000000..a60ae84 --- /dev/null +++ b/tests/test_freecad_importer.py @@ -0,0 +1,67 @@ +import os +from pathlib import Path +import pytest +from plugins.freecad_import.freecad_importer import ( + import_freecad_part, + get_freecad_part_parameters, +) + + +def test_static_import(): + """ + Imports a sample model without trying to change its parameters. + """ + + # We need to find the path to our FreeCAD test files + test_dir_path = Path(__file__).resolve().parent + static_file_path = os.path.join(test_dir_path, "testdata", "box.FCStd") + + # Perform the import of the FreeCA file into CadQuery + result = import_freecad_part(static_file_path) + + # Make sure the box quacks like a box + assert result.vertices().size() == 8 + assert result.edges().size() == 12 + assert result.faces().size() == 6 + + +def test_parametric_import(): + """ + Imports a sample model while altering its parameters. + """ + + # We need to find the path to our FreeCAD test files + test_dir_path = Path(__file__).resolve().parent + parametric_file_path = os.path.join(test_dir_path, "testdata", "base_shelf.FCStd") + + # First import the model with default parameters and make sure the volume is as expected + result = import_freecad_part(parametric_file_path) + vol = result.val().Volume() + assert vol > 46742 and vol < 46743 + + # Import the model with modified parameters and make sure the volume has decreased by the appropriate amount + result = import_freecad_part( + parametric_file_path, parameters={"mount_dia": {"value": 4.8, "units": "mm"}} + ) + vol = result.val().Volume() + assert vol > 46634 and vol < 46635 + + +def test_get_parameters(): + """ + Retrieves parameters from a model. + """ + + # We need to find the path to our FreeCAD test files + test_dir_path = Path(__file__).resolve().parent + parametric_file_path = os.path.join(test_dir_path, "testdata", "base_shelf.FCStd") + + # Get the parameters from the objectr + params = get_freecad_part_parameters( + parametric_file_path, name_column_letter="A", value_column_letter="B" + ) + + # Make sure that the correct numbers of parameters are present and do a spot check on the presence of parameters + assert len(params) == 20 + assert "mount_dia" in params + assert "internal_rail_spacing" in params diff --git a/tests/testdata/README.md b/tests/testdata/README.md new file mode 100644 index 0000000..77a4897 --- /dev/null +++ b/tests/testdata/README.md @@ -0,0 +1 @@ +The base_shelf.FCStd file came from the [Nimble project repository](https://github.com/Wakoma/nimble/tree/master/src/mech/freecad) and the same license and attribution applies as is set on that repository. \ No newline at end of file diff --git a/tests/testdata/base_shelf.FCStd b/tests/testdata/base_shelf.FCStd new file mode 100755 index 0000000..287f8ad Binary files /dev/null and b/tests/testdata/base_shelf.FCStd differ diff --git a/tests/testdata/box.FCStd b/tests/testdata/box.FCStd new file mode 100755 index 0000000..df80874 Binary files /dev/null and b/tests/testdata/box.FCStd differ