Repository: michaelgale/cq-gridfinity Branch: main Commit: d317286ec15d Files: 37 Total size: 203.8 KB Directory structure: gitextract_j4oewo7c/ ├── .devcontainer/ │ ├── Dockerfile │ ├── devcontainer.json │ └── entrypoint.sh ├── .github/ │ └── workflows/ │ └── checks.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── cqgridfinity/ │ ├── __init__.py │ ├── constants.py │ ├── gf_baseplate.py │ ├── gf_box.py │ ├── gf_drawer.py │ ├── gf_helpers.py │ ├── gf_obj.py │ ├── gf_ruggedbox.py │ ├── scripts/ │ │ ├── __init__.py │ │ ├── gridfinitybase.py │ │ ├── gridfinitybox.py │ │ └── ruggedbox.py │ └── shims/ │ ├── README.md │ ├── cqgi_gf_baseplate.py │ ├── cqgi_gf_box.py │ ├── cqgi_gf_drawerspacer.py │ └── cqgi_gf_ruggedbox.py ├── examples/ │ └── demo1.assy ├── partcad.yaml ├── requirements.in ├── requirements.txt ├── setup.py └── tests/ ├── common_test.py ├── test_baseplate.py ├── test_box.py ├── test_rbox.py ├── test_spacer.py └── testfiles/ └── .gitkeep ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM condaforge/mambaforge:23.3.1-1 # Create non-root user ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \ && apt-get update \ && apt-get install -y sudo \ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ && chmod 0440 /etc/sudoers.d/$USERNAME # Install system dependencies RUN export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends \ git=1:2.* \ libgl1-mesa-dev \ xvfb \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # Create conda environment and install packages RUN mamba create -n cqdev python=3.12 -y \ && mamba init bash \ && mamba install -n cqdev -c conda-forge -y \ cadquery=2.4 \ pytest=8.* \ black=24.* \ flake8=7.* \ isort=5.* \ && mamba clean --all -f -y # Install pip dependencies SHELL ["/bin/bash", "-c"] RUN source /opt/conda/etc/profile.d/conda.sh \ && conda activate cqdev \ && pip install --no-cache-dir \ ezdxf==1.* \ cqkit \ importlib_metadata # Set up X11 and OpenGL RUN mkdir -p /tmp/runtime-root \ && chown ${USERNAME}:${USERNAME} /tmp/runtime-root ENV XDG_RUNTIME_DIR=/tmp/runtime-root ENV DISPLAY=:99 ENV LIBGL_ALWAYS_INDIRECT=1 # Configure conda environment ENV PATH=/opt/conda/envs/cqdev/bin:$PATH RUN echo "conda activate cqdev" >> /home/${USERNAME}/.bashrc # Set up virtual framebuffer COPY .devcontainer/entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/entrypoint.sh # Switch to non-root user USER ${USERNAME} ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["sleep", "infinity"] ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "CQ Gridfinity Development", "build": { "dockerfile": "Dockerfile", "context": ".." }, "workspaceFolder": "/workspaces/cq-gridfinity", "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/cq-gridfinity,type=bind", "customizations": { "vscode": { "extensions": [ "ms-python.python", "ms-python.vscode-pylance", "ms-python.black-formatter", "ms-python.flake8", "ms-python.isort" ], "settings": { "python.defaultInterpreterPath": "/opt/conda/envs/cqdev/bin/python", "python.testing.pytestEnabled": true, "python.condaPath": "/opt/conda/bin/conda" } } }, "forwardPorts": [ 8080 ], "postCreateCommand": "pip install -e .", "remoteUser": "vscode" } ================================================ FILE: .devcontainer/entrypoint.sh ================================================ #!/bin/bash source /opt/conda/etc/profile.d/conda.sh conda activate cqdev Xvfb :99 -screen 0 1024x768x16 & sleep 1 exec "$@" ================================================ FILE: .github/workflows/checks.yaml ================================================ name: Run Tests on: pull_request: branches: - main # Runs on pull requests targeting the main branch jobs: tests: name: Test (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} defaults: run: shell: bash -l {0} strategy: fail-fast: false matrix: os: ["ubuntu-latest"] python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - name: Check out code uses: actions/checkout@v4 - uses: conda-incubator/setup-miniconda@v2 with: python-version: ${{ matrix.python-version }} mamba-version: "*" channels: conda-forge,defaults channel-priority: true activate-environment: cq - name: Install cadquery run: mamba install cadquery - name: Install pip dependencies run: pip install cqkit pytest - name: Install current version of cq-gridfinity run: pip install . - name: Run tests run: pytest tests ================================================ FILE: .gitignore ================================================ # python intermediate files *.py[cod] # intermediate and cached 3D solid files /cache/* /tests/testfiles/*.step /tests/testfiles/*.iges /tests/testfiles/*.stl *.graffle # editors /.vscode # C extensions *.so # OS litter .DS_Store Desktop.ini ._* Thumbs.db .Trashes # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 __pycache__ # Installer logs pip-log.txt # Unit test / coverage reports .coverage .tox nosetests.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject .venv/ ================================================ FILE: CHANGELOG.md ================================================ ## Changelog - v.0.1.0 - Initial release - v.0.1.1 - fixed release - v.0.2.0 - Added new "lite" style box - v.0.2.1 - Added new unsupported magnet hole types - v.0.2.2 - Added SVG export and integrated STL exporter - v.0.2.3 - Updated to python build tools to make distribution - v.0.3.0 - Added console generator scripts: gridfinitybox and gridfinitybase - v.0.4.0 - Added `GridfinityRuggedBox` class and `ruggedbox` console script. Various other improvements. - v.0.4.1 - Fixed docstring in `__init__.py` - v.0.4.2 - Improved script automatic renaming - v.0.4.3 - Fixed regression bug with using multilevel extrusion functions from cq-kit - v.0.4.4 - IMPORTANT FIX: generated geometry breaks using CadQuery v.2.4+ due to changes in CadQuery's `extrude` method. This version should work with any CQ version since it detects which CQ extrusion implementation is used at runtime. - v.0.4.5 - IMPORTANT FIX: fixes error in v.0.4.4 for extrusion angle - v.0.5.0 - Improved rugged box to make viable boxes down to 3U x 3U x 4U - v.0.5.1 - Increased the resolution of the gridfinity extruded base profile - v.0.5.2 - Adjusted geometry of box/bin floor/lip heights to exactly 7.00 mm intervals - v.0.5.3 - Removed a potential namespace collision for computing the height of boxes - v.0.5.4 - Optimized the geometry of the baseplate top height - v.0.5.5 - Added underside bin clearance and variable wall thickness interior radiusing - v.0.5.6 - Added adjustable magnet hole diameter to box. Prevent drawer spacers being rendered which fall below minimum size ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2023 Michael Gale Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: clean clean-test clean-pyc clean-build test .DEFAULT_GOAL := help define PRINT_HELP_PYSCRIPT import re, sys for line in sys.stdin: match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) if match: target, help = match.groups() print("%-20s %s" % (target, help)) endef export PRINT_HELP_PYSCRIPT help: @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts clean-build: ## remove build artifacts @rm -fr build/ @rm -fr dist/ @rm -fr .eggs/ @find . -name '*.egg-info' -exec rm -fr {} + @find . -name '*.egg' -exec rm -f {} + clean-pyc: ## remove Python file artifacts @find . -name '*.pyc' -exec rm -f {} + @find . -name '*.pyo' -exec rm -f {} + @find . -name '*~' -exec rm -f {} + @find . -name '__pycache__' -exec rm -fr {} + clean-test: ## remove test and coverage artifacts @find . -name '*.step' -exec rm -f {} + @find . -name '*.stl' -exec rm -f {} + @find . -name '*.svg' -exec rm -f {} + @rm -f .coverage @rm -fr htmlcov/ lint: ## check style with black @black cqgridfinity/*.py @black cqgridfinity/scripts/*.py @black tests/*.py lint-check: ## check if lint status is consistent between commits @black --diff --check cqgridfinity/*.py @black --diff --check cqgridfinity/scripts/*.py @black --diff --check tests/*.py test: ## run tests quickly with the default Python py.test -s -v --cov -W ignore::DeprecationWarning:nptyping.typing_ # @export SKIP_TEST_BOX="all" && \ # export SKIP_TEST_RBOX="all" && \ # export SKIP_TEST_SPACER="all" && \ # export SKIP_TEST_BASEPLATE="all" && \ # export EXPORT_STEP_FILES="all" && \ test-some: ## run selective tests quickly with the default Python @export SKIP_TEST_BOX="all" && \ export SKIP_TEST_RBOX="all" && \ export SKIP_TEST_BASEPLATE="all" && \ export EXPORT_STEP_FILES="all" && \ py.test -s -v --cov -W ignore::DeprecationWarning:nptyping.typing_ test-files: ## run tests and export test files artifacts @export EXPORT_STEP_FILES="all" && \ py.test -s -v -W ignore::DeprecationWarning:nptyping.typing_ coverage: ## check code coverage quickly with the default Python coverage run --source cqgridfinity -m pytest coverage report -m coverage html open htmlcov/index.html release: clean dist ## package and upload a release twine check dist/* twine upload dist/* dist: clean ## builds source and wheel package @python -m build @twine check dist/* @ls -l dist install: clean ## install the package to the active Python's site-packages @pip install . ================================================ FILE: README.md ================================================ ![cq-gridfinity Logo](./images/logo.png) # cq-gridfinity [![](https://img.shields.io/pypi/v/cqgridfinity.svg)](https://pypi.org/project/cqgridfinity/) ![python version](https://img.shields.io/static/v1?label=python&message=3.9%2B&color=blue&style=flat&logo=python) [![](https://img.shields.io/static/v1?label=dependencies&message=CadQuery%202.0%2B&color=blue&style=flat)](https://github.com/CadQuery/cadquery) [![](https://img.shields.io/badge/CQ--kit-blue)](https://github.com/michaelgale/cq-kit) ![https://github.com/michaelgale/cq-kit/blob/master/LICENSE](https://img.shields.io/badge/license-MIT-blue.svg) [![](https://img.shields.io/badge/code%20style-black-black.svg)](http://github.com/psf/black) [![Run Tests](https://github.com/michaelgale/cq-gridfinity/actions/workflows/checks.yaml/badge.svg)](https://github.com/michaelgale/cq-gridfinity/actions/workflows/checks.yaml) This repository contains a python library to build [Gridfinity](https://gridfinity.xyz) boxes, baseplates, and other objects based on the [CadQuery](https://github.com/CadQuery/cadquery) python library. The Gridfinity system was created by [Zach Freedman](https://www.youtube.com/c/ZackFreedman) as a versatile system of modular organization and storage modules. A vibrant community of user contributed modules and utilities has grown around the Gridfinity system. This repository contains python classes to create gridfinity compatible parameterized components such as baseplates and boxes. Examples of how I am starting to use Gridfinity to organize my tools are shown below using components built with this python library: # Quick Links - [Installation / Usage](#installation) - [Shell Command Scripts](#shell-command-scripts) - [gridfinitybox](#gridfinitybox) - [gridfinitybase](#gridfinitybase) - [ruggedbox](#ruggedbox) - [Classes](#classes) - [GridfinityBaseplate](#gridfinitybaseplate) - [GridfinityBox](#gridfinitybox-1) - [GridfinityDrawerSpacer](#gridfinitydrawerspacer) - [GridfinityRuggedBox](#gridfinityruggedbox) - [GridfinityObject](#gridfinityobject) - [References](#references) ## Installation **cq-gridfinity** has the following installation dependencies: - [CadQuery](https://github.com/CadQuery/cadquery) - [cq-kit](https://github.com/michaelgale/cq-kit) Assuming these dependencie are installed, you can install **cq-gridfinity** using a [PyPI package](https://pypi.org/project/cqgridfinity/) as follows: ```bash $ pip install cqgridfinity ``` Alternatively, the **cq-gridfinity** package can be installed directly from the source code: ```bash $ git clone https://github.com/michaelgale/cq-gridfinity.git $ cd cq-gridfinity $ pip install . ``` ## Development with VS Code Dev Container This project includes a development container configuration that provides a consistent development environment with all required dependencies pre-installed. ### Prerequisites 1. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/) 2. Install [Visual Studio Code](https://code.visualstudio.com/) 3. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) in VS Code ### Getting Started 1. Clone this repository: ```bash git clone https://github.com/michaelgale/cq-gridfinity.git cd cq-gridfinity ``` 2. Open the project in VS Code: ```bash code . ``` 3. When VS Code detects the dev container configuration, it will prompt you to "Reopen in Container". Click this prompt, or: - Press `F1` or `Ctrl+Shift+P` (Cmd+Shift+P on Mac) - Type "Dev Containers: Reopen in Container" and select it 4. Wait for the container to build (this may take a few minutes the first time) ### What's Included The development container comes with: - Python 3.12 - CadQuery 2.4 - All required dependencies (pytest, black, flake8, etc.) - A pre-configured environment for CAD development ### Troubleshooting If you encounter issues: 1. Ensure Docker is running 2. Try rebuilding the container: - Press `F1` or `Ctrl+Shift+P` - Select "Dev Containers: Rebuild Container" ## Basic Usage After installation, the package can imported: ```shell $ python >>> import cqgridfinity >>> cqgridfinity.__version__ ``` An example of the package can be seen below: ```python from cqgridfinity import * # make a simple box box = GridfinityBox(3, 2, 5, holes=True, no_lip=False, scoops=True, labels=True) box.save_stl_file() # Output a STL file of box: # gf_box_3x2x5_holes_scoops_labels.stl ``` # Shell Command Scripts - [gridfinitybox](#gridfinitybox) - [gridfinitybase](#gridfinitybase) - [ruggedbox](#ruggedbox) This package can be used to make your own python scripts to generate Gridfinity objects. This gives the flexibility to customize the object and combine with other code to add custom cutouts, add text labels, etc. However, for simple generation of standard objects such as baseplates and boxes, console scripts can be used for quick command line usage. These console scripts are installed automatically into the path of your python environment and should be accessible from your terminal shell. ## `gridfinitybox` Make a customized/parameterized Gridfinity compatible box with many optional features. ``` usage: gridfinitybox [-h] [-m] [-u] [-n] [-s] [-l] [-e] [-d] [-r RATIO] [-ld LENGTHDIV] [-wd WIDTHDIV] [-wt WALL] [-f FORMAT] [-o OUTPUT] length width height Make a customized/parameterized Gridfinity compatible box with many optional features. positional arguments: length Box length in U (1U = 42 mm) width Box width in U (1U = 42 mm) height Box height in U (1U = 7 mm) options: -h, --help show this help message and exit -m, --magnetholes Add bottom magnet/mounting holes -u, --unsupported Add bottom magnet holes with 3D printer friendly strips without support -n, --nolip Do not add mating lip to the top perimeter -s, --scoops Add finger scoops against each length-wise back wall -l, --labels Add label strips against each length-wise front wall -e, --ecolite Make economy / lite style box with no elevated floor -d, --solid Make solid (filled) box for customized storage -r RATIO, --ratio RATIO Solid box fill ratio 0.0 = minimum, 1.0 = full height -ld LENGTHDIV, --lengthdiv LENGTHDIV Split box length-wise with specified number of divider walls -wd WIDTHDIV, --widthdiv WIDTHDIV Split box width-wise with specified number of divider walls -wt WALL, --wall WALL Wall thickness (default=1 mm) -f FORMAT, --format FORMAT Output file format (STEP, STL, SVG) default=STEP -o OUTPUT, --output OUTPUT Output filename (inferred output file format with extension) ``` Examples: ```shell # 2x3x5 box with magnet holes saved to STL file with default filename: $ gridfinitybox 2 3 5 -m -f stl # gf_box_2x3x5_holes.stl # 1x3x4 box with scoops, label strip, 3 internal partitions and specified name: $ gridfinitybox 1 3 4 -s -l -ld 3 -o MyBox.step # MyBox.step # Solid 3x3x3 box with 50% fill, unsupported magnet holes and no top lip: $ gridfinitybox 3 3 3 -d -r 0.5 -u -n # gf_box_3x3x3_basic_holes_solid.step # Lite style box 3x2x3 with label strip, partitions, output to default SVG file: $ gridfinitybox 3 2 3 -e -l -ld 2 -f svg # gf_box_lite_3x2x3_div2_labels.svg ``` ## `gridfinitybase` Make a customized/parameterized Gridfinity compatible simple baseplate. ``` usage: gridfinitybase [-h] [-f FORMAT] [-s] [-d DEPTH] [-hd HOLEDIAM] [-hc CSKDIAM] [-ca CSKANGLE] [-o OUTPUT] length width Make a customized/parameterized Gridfinity compatible simple baseplate. positional arguments: length Box length in U (1U = 42 mm) width Box width in U (1U = 42 mm) options: -h, --help show this help message and exit -f FORMAT, --format FORMAT Output file format (STEP, STL, SVG) default=STEP -s, --screws Add screw mounting tabs to the corners (adds +5 mm to depth) -d DEPTH, --depth DEPTH Extrude extended depth under baseplate by this amount -hd HOLEDIAM, --holediam HOLEDIAM Corner mounting screw hole diameter (default=5) -hc CSKDIAM, --cskdiam CSKDIAM Corner mounting screw countersink diameter (default=10) -ca CSKANGLE, --cskangle CSKANGLE Corner mounting screw countersink angle (deg) (default=82) -o OUTPUT, --output OUTPUT Output filename (inferred output file format with extension) ``` Examples: ```shell # 7 x 4 baseplate with screw corners to default STL file: $ gridfinitybase 7 4 -s -f stl # gf_baseplate_7x4x5.0_screwtabs.stl ``` ## `ruggedbox` Make a parameterized rugged storage box compatible with gridfinity. This box is based on the [superb design by Pred on Printables](https://www.printables.com/model/543553-gridfinity-storage-box-by-pred-now-parametric). This implementation makes a few improvements and additions to Pred's design in addition to making almost all of the box's features optional and tunable. Using either the `ruggedbox` console script or the `GridfinityRuggedBox` class, you can make vast variety of different boxes of various sizes and features. By default, almost all of the boxes features are enabled, but by using the desired command line options you can customize your desired feature set. ``` usage: ruggedbox [-h] [+l] [-l] [+p] [-p] [+w] [-w] [-wt WINDOWTHICKNESS] [+a] [-a] [+c] [-c] [+s] [-s] [+v] [-v] [+e] [-e] [+b] [-b] [-r] [+r] [-f FORMAT] [-o OUTPUT] [-gb] [-gl] [-ga] [-gh] [-ge] [-gn] [-gt] [-gw] length width height Make a customized/parameterized Gridfinity compatible rugged box enclosure. The minimum box size is 3U x 3U x 4U. positional arguments: length Box length in U (1U = 42 mm) width Box width in U (1U = 42 mm) height Box height in U (1U = 7 mm) options: -h, --help show this help message and exit +l, --label Add label window across the front wall -l, --nolabel Remove label window across the front wall +p, --lidbaseplate Add baseplate to top of the lid -p, --nolidbaseplate Smooth/plain lid +w, --lidwindow Add window slot to the lid -w, --nolidwindow Do not add window slot to the lid -wt WINDOWTHICKNESS, --windowthickness WINDOWTHICKNESS Thickness of lid windows (mm) +a, --handle Add front handle -a, --nohandle No front handle +c, --clasps Add clasps to the left and right side walls -c, --noclasps No clasps on the left and right side walls +s, --stackable Add stackable mating features to top and bottom -s, --notstackable Non-stackable box +v, --veegroove Add v-cut grooves to side walls -v, --noveegroove No v-cut grooves (plain) side walls +e, --sidehandle Add handles to side walls -e, --nosidehandle No handles on side walls +b, --backfeet Add standing feet to back wall -b, --nobackfeet No standing feet added to back wall -r, --normalstyle Make normal style box +r, --ribstyle Make rib style box with exposed vertical ribs -f FORMAT, --format FORMAT Output file format (STEP, STL, SVG) default=STEP -o OUTPUT, --output OUTPUT Output filename (inferred output file format with extension) -gb, --box Generate box -gl, --lid Generate lid -ga, --acc Generate accessory components -gh, --hinge Generate hinge element -ge, --genlabel Generate label panel insert -gn, --genhandle Generate front handle -gt, --genlatch Generate latch component -gw, --genwindow Generate lid window component example usage: 5 x 4 x 6 rugged box shell and lid saved to STL files: $ ruggedbox 5 4 6 --box --lid -f stl ``` Examples: 5 x 4 x 6 rugged box component saved to STL file: ```shell $ ruggedbox 5 4 6 -gb -f stl ____ _ ____ | _ \ _ _ __ _ __ _ ___ __| | __ ) _____ __ | |_) | | | |/ _` |/ _` |/ _ \/ _` | _ \ / _ \ \/ / | _ <| |_| | (_| | (_| | __/ (_| | |_) | (_) > < |_| \_\\__,_|\__, |\__, |\___|\__,_|____/ \___/_/\_\ |___/ |___/ Version: 0.5.7 Gridfinity rugged box: 5U x 4U x 6U Exterior dim: 230.0 mm x 188.0 mm x 55.0 mm Interior dim: 210.0 mm x 168.0 mm x 45.8 mm Internal volume: 1.616 L Wall Vgrooves : Y Front Handle : Y Stackable : Y Side Clasps : Y Lid Baseplate : Y Inside Baseplate : Y Side Handles : Y Front Label : Y Back Feet : Y Rib Style : N Lid Window : N Rendering box... Component generated and saved as gf_ruggedbox_5x4x6_body_fr-hl_sd-hc_stack_lidbp.stl in STL format $ ``` ```shell # same 5 x 4 x 6 rugged box with the lid saved to STL file: $ ruggedbox 5 4 6 --lid -f stl # gf_ruggedbox_5x4x6_lid_fr-hl_sd-hc_stack_lidbp.stl # 5 x 5 x 9 rugged box, smooth lid, non-stackable, and no handle; full assembly saved to STEP file $ ruggedbox 5 5 9 --nohandle --nolidbaseplate --notstackable # gf_ruggedbox_5x5x9_fr-l_sd-hc.step # Render the box, lid, and hinge for a 5x4x6 rugged box all at once: $ ruggedbox 5 4 6 --box --lid --hinge # gf_ruggedbox_5x4x6_fr-hl_sd-hc_stack_lidbp.step # gf_ruggedbox_5x4x6_lid_fr-hl_sd-hc_stack_lidbp.step # gf_ruggedbox_5x4x6_hinge_fr-hl_sd-hc_stack_lidbp.step # Then render the latches and handle components for the same box: $ ruggedbox 5 4 6 --acc # gf_ruggedbox_5x4x6_acc_fr-hl_sd-hc_stack_lidbp.step # Or render individual components as STL files with your preferred name: $ ruggedbox 5 4 6 --genhandle --genlatch -o orange.stl # orange_handle.stl # orange_latch.stl ``` # Classes - [GridfinityBaseplate](#gridfinitybaseplate) - [GridfinityBox](#gridfinitybox-1) - [GridfinityDrawerSpacer](#gridfinitydrawerspacer) - [GridfinityRuggedBox](#gridfinityruggedbox) - [GridfinityObject](#gridfinityobject) ## `GridfinityBaseplate` Gridfinity baseplates can be made with the `GridfinityBaseplate` class. The baseplate style is the basic style initially proposed by Zach Freedman. Therefore, it does not have magnet or mounting holes. An example usage is as follows: ```python # Create 4 x 3 baseplate baseplate = GridfinityBaseplate(4, 3) baseplate.save_step_file() # gf_baseplate_4x3.step ``` Baseplates can be rendered with extra depth to make a taller overall baseplate using the `ext_depth` attribute. Furthermore, mounting screws corner tabs can be added to the baseplate with the `corner_screws` attribute. A baseplate with this feature is shown below. ### Optional Keyword Arguments ```python ext_depth = 0 # extended depth to extrude below baseplate straight_bottom = False # add/remove 0.8 mm chamfer on bottom of baseplate corner_screws = False # add corner mounting screw tabs corner_tab_size = 21 # size of screw mounting tab (mm) csk_hole = 5.0 # hole diameter of countersink mounting screw (mm) csk_diam = 10.0 # countersink diameter (mm) csk_angle = 82 # countersink angle (deg) ``` ## `GridfinityBox` Gridfinity boxes with many optional features can be created with the `GridfinityBox` class. As a minimum, this class is initialized with basic 3D unit dimensions for length, width, and height. The length and width are multiples of 42 mm Gridfinity intervals and height represents multiples of 7 mm. ### Simple Box ```python # Create a simple 3 x 2 box, 5U high box = GridfinityBox(3, 2, 5) box.save_step_file() # Output a STEP file of box named: # gf_box_3x2x5.step ``` ### Lite Style Box "Lite" style boxes are simplified for faster 3D printing with less material. They remove the continuous floor at 7.2 mm and the box becomes a homogenous 1 mm thick walled shell. "lite" style boxes can include labels and dividers; however, the number of dividers must correspond to the same bottom partition ridges, i.e. `length_div` must be `length_u - 1` and `width_div` must be `width_u - 1`. "lite" style cannot be combined with solid boxes, finger scoops, or magnet holes. ```python # Create a "lite" style 3 x 2 box, 5U high box = GridfinityBox(3, 2, 5, lite_style=True) box.save_step_file() # Output a STEP file of box named: # gf_box_lite_3x2x5.step ``` ### Magnet Holes ```python # add magnet holes to the box box = GridfinityBox(3, 2, 5, holes=True) box.save_step_file() # gf_box_3x2x5_holes.step ``` The `unsupported_holes` attribute can specify either regular holes or modified/unsupported holes which are more suitable for 3D-printing. These modified holes include thin filler strips which allow the slicer to avoid using supports to render the underside holes. ```python # add magnet holes to the box box = GridfinityBox(1, 1, 5, holes=True, unsupported_holes=True) box.save_step_file() # gf_box_1x1x5_holes.step ``` ### Simple Box with No Top Lip ```python # remove top mounting lip box = GridfinityBox(3, 2, 5, no_lip=True) box.save_step_file() # gf_box_3x2x5_basic.step ``` ### Scoops and Labels ```python # add finger scoops and label top flange box = GridfinityBox(3, 2, 5, scoops=True, labels=True) box.save_step_file() # gf_box_3x2x5_scoops_labels.step ``` ### Dividing Walls ```python # add dividing walls box = GridfinityBox(3, 2, 5, length_div=2, width_div=1, scoops=True, labels=True) box.save_step_file() # gf_box_3x2x5_div2x1_scoops_labels.step ``` ### Solid Box ```python # make a partially solid box box = GridfinityBox(3, 2, 5, solid=True, solid_ratio=0.7) box.save_step_file() # gf_box_3x2x5_solid.step ``` ### Optional Keyword Arguments ```python length_div=0 # add dividing walls along length width_div=0 # add dividing walls along width holes=False # add magnet holes to bottom unsupported_holes=False # 3D-printer friendly hole style requiring no supports no_lip=False # remove top mating lip feature scoops=False # add finger scoops scoop_rad=11 # radius of optional interior scoops labels=False # add a label flange to the top label_width=12 # width of the label strip label_height=10 # thickness height of label overhang label_lip_height=0.8 # thickness of label vertical lip lite_style=False # make a "lite" version of box without elevated floor solid=False # make a solid box solid_ratio=1.0 # ratio of solid height range 0.0 to 1.0 (max height) wall_th=1.0 # wall thickness (0.5-2.5 mm) fillet_interior=True # enable/disable internal fillet edges ``` ## `GridfinityDrawerSpacer` The `GridfinityDrawerSpacer` class can be used to make spacer components to fit a drawer with any arbitrary dimensions. Initialize with specified width and depth of the drawer (in mm) and the best fit of integer gridfinity baseplate units is computed. Rarely, integer multiples of 42 mm gridfinity baseplates fit perfectly inside a drawer; therefore, spacers are required to secure the baseplate snuggly inside the drawer. Spacers consist of 4x identical corner sections, 2x spacers for the left and right sides and 2x spacers for the front and back edges. If the computed spacer width falls below a configurable threshold (default 4 mm), then no spacer component is made in that dimension. The spacer components are made by default with interlocking "jigsaw" type features to assist with assembly and to secure the spacers within the drawer. Also, alignment arrows (default but optional) are placed on the components to indicate the installation orientation in the direction of the drawer movement. ```python # make drawer spacers for Craftsman tool chest drawer 23" wide x 19" deep spacer = GridfinityDrawerSpacer(582, 481, verbose=True) # Best fit for 582.00 x 481.00 mm is 13U x 11U # with 18.00 mm margin each side and 9.50 mm margin front and back # Corner spacers : 4U wide x 3U deep # Front/back spacers : 5U wide x 9.25 mm +0.25 mm tolerance # Left/right spacers : 5U deep x 17.75 mm +0.25 mm tolerance ``` A full set of components (optionally including a full baseplate) can be rendered with the `render_full_set()` method. This method is mostly used to verify the fit and placement of the spacers. Normally, the `render_half_set()` method used to render half of the components compactly arranged conveniently for 3D printing. This set can be printed twice to make a full set for a single drawer. ### Optional Keyword Arguments ```python thickness=GR_BASE_HEIGHT # thickness of spacers, default=5 mm chamf_rad=1.0 # chamfer radius of spacer top/bottom edges show_arrows=True # show orientation arrows indicating drawer in/out direction align_features=True # add "jigsaw" interlocking feautures align_tol=0.15 # tolerance of the interlocking joint align_min=8 # minimum spacer width for adding interlocking feature min_margin=4 # minimum size to make a spacer, nothing is made for smaller gaps tolerance=GR_TOL # overall tolerance for spacer components, default=0.5 mm ``` ### Example with IKEA ALEX narrow drawer An example use case to make a set of spacer components for a typical IKEA narrow ALEX drawer is as follows: ```python spacers = GridfinityDrawerSpacer(INCHES(11.5), INCHES(20.5), verbose=True) spacers.render_full_set(include_baseplate=True) spacers.save_step_file("ikea_alex_full_set.step") # make a half set for 3D printing spacers.render_half_set() spacers.save_stl_file("ikea_alex_half_set.stl") ``` ## `GridfinityRuggedBox` The `GridfinityRuggedBox` class can be used to make gridfinity compatible rugged storage boxes. This box is based on the [superb design by Pred on Printables](https://www.printables.com/model/543553-gridfinity-storage-box-by-pred-now-parametric). The **cq-gridfinity** derivative version of Pred's box is completely parameterized and generated completely with code in the `GridfinityRuggedBox` class. This lets you render the most minimalist box configuration with no added features up to a full-featured box as shown below: The desired box size and features are specified with keyword arguments/attributes such as the ones illustrated below: A alternative "rib style" rugged box is also available. This adds vertical rib stiffeners around the perimeter of the box and it is recommended to disable the side handles to allow for ribs to be generated on all sides. Lastly, the lid baseplate can be substituted with a lid window which makes the contents of the box visible. The window consists of a seperately prepared 1 mm thick transparent acrylic sheet cut to the required dimensions. These dimensions can be queried with the `lid_window_size()` method or will be printed to the console when using the `ruggedbox` shell script. After the lid has been printed the process to install the lid window is as follows: 1. Cut the lid window to the required dimensions. It is recommended to chamfer or round off the leading edge corners with a file prior to insertion. 2. Slide the window into the lid starting from the back and along the tapered window groove slot around the inside perimeter of the lid. 3. The window should be inserted just past the retention slots for the hinges. 4. Secure the lid with 3x M2 screws along the back of the lid. Carefully drill 2.5 mm clearance holes into the window in situ prior to installation of the screws. Alternatively, the lid can be secured with a few drops of super glue along the rear edge. 5. Install the lid hinges. The hinges must be installed last since they act as a physical retainer along the back edge of the window. The lid window should nominally be 1 mm thick; however if it necessary to use a different thickness material, the `window_th` attribute can be set. It recommended to keep the window thickness in the range of 0.8 to 1.6 mm. The rugged box can be rendered either as a complete assembly or as individual components. This is useful for making individual asset files for 3D printing. The render methods include the `render_assembly()` method as shown above for the complete assembly, as well as individual render methods summarized below: `render()` - renders just the main box body shell: `render_lid()` - renders the lid: `render_accessories()` - renders the accessory component elements as a group in the quantities required for the desired box: Lastly, each individual component has an individual render method. - `render_hinge()` - `render_latch()` - `render_label()` - `render_handle()` ### Optional Keyword Arguments ```python lid_height = 10 # lid height (should be multiple of 10 mm for stacking) wall_vgrooves = True # enable horizontal v-grooves to body shell front_handle = True # enable front handle stackable = True # add mating stackable features side_clasps = True # add extra side latching clasps lid_baseplate = True # enable top/lid baseplate inside_baseplate = True # enable interior baseplate side_handles = True # enable side handles to box front_label = True # enable front label panel label_length = None # length of front label panel, None=auto size label_height = None # height of front label panel, None=auto size label_th = GR_LABEL_TH # thickness of label panel, default=0.5 mm back_feet = True # add rear back feet matching hinges to allow the stand box vertically hinge_width = GR_HINGE_SZ # Size of hinge, default=32 mm hinge_bolted = False # printed or bolted hinge construction box_color = cq.Color(0.25, 0.25, 0.25) # colors for the assembly STEP file lid_color = cq.Color(0.25, 0.5, 0.75) handle_color = cq.Color(0.75, 0.5, 0.25) latch_color = cq.Color(0.75, 0.5, 0.25) hinge_color = cq.Color(0.75, 0.5, 0.25) label_color = cq.Color(0.7, 0.7, 0.7) ``` ## `GridfinityObject` The `GridfinityObject` is the base class for `GridfinityBox`, `GridfinityBaseplate`, etc. It has several useful methods and attributes including: ### File export and naming `obj.filename(self, prefix=None, path=None)` returns a filename string with descriptive attributes such as the object size and enabled features. ```python box = GridfinityBox(3, 2, 5, holes=True) box.filename() # gf_box_3x2x5_holes box.filename(prefix="MyBox") # MyBox_3x2x5_holes box.filename(path="./outputfiles") # ./outputfiles/gf_box_3x2x5_holes box2 = GridfinityBox(4, 3, 3, holes=True, length_div=2, width_div=1) box2.filename() # gf_box_4x3x3_holes_div2x1 ``` ```python # Export object to STEP, STL, or SVG file obj.save_step_file(filename=None, path=None, prefix=None) obj.save_stl_file(filename=None, path=None, prefix=None) obj.save_svg_file(filename=None, path=None, prefix=None) ``` The automatic filename assignment is aware of the last object generated with a particular class's render method. Therefore, you can call any render method and then call any of the `save_step_file`, `save_stl_file`, `save_svg_file` methods and the filename will adapt to the last object rendered. For example: ```python b1 = GridfinityRuggedBox(5, 4, 6) b1.render_accessories() b1.save_step_file() # saved as "gf_ruggedbox_5x4x6_acc_fr-hl_sd-hc_stack_lidbp.step" b1.render_handle() b1.save_stl_file() # saved as "gf_ruggedbox_5x4x6_handle_fr-hl_sd-hc_stack_lidbp.stl" b1.render_hinge() b1.save_svg_file(path="./mystuff") # saved as "./mystuff/gf_ruggedbox_5x4x6_hinge_fr-hl_sd-hc_stack_lidbp.svg" b1.render_assembly() b1.save_step_file() # saved as "gf_ruggedbox_5x4x6_assembly_fr-hl_sd-hc_stack_lidbp.step" ``` ### Useful properties ```obj.cq_obj``` returns a rendered CadQuery Workplane object ```obj.length``` returns length in mm ```obj.width``` returns width in mm ```obj.height``` returns height in mm ```obj.top_ref_height``` returns the height of the top surface of a solid box or the floor height of an empty box. This can be useful for making custom boxes with cutouts since the reference height can be used to orient the cutting solid to the correct height. # To-do - add more example scripts - improve documentation # Releases - v.0.1.0 - First release on PyPI - v.0.1.1 - Fixed release - v.0.2.0 - Added new "lite" style box - v.0.2.1 - Added new unsupported magnet hole types - v.0.2.2 - Added SVG export and integrated STL exporter - v.0.2.3 - Updated to python build tools to make distribution - v.0.3.0 - Added console generator scripts: `gridfinitybox` and `gridfinitybase` - v.0.4.0 - Added `GridfinityRuggedBox` class and `ruggedbox` console script. Various other improvements. - v.0.4.1 - Fixed docstring in `__init__.py` - v.0.4.2 - Improved script automatic renaming - v.0.4.3 - Fixed regression bug with using multilevel extrusion functions from cq-kit - v.0.4.4 - IMPORTANT FIX: generated geometry breaks using CadQuery v.2.4+ due to changes in CadQuery's `extrude` method. This version should work with any CQ version since it detects which CQ extrusion implementation is used at runtime. - v.0.4.5 - IMPORTANT FIX: fixes error in v.0.4.4 for extrusion angle - v.0.5.0 - Improved rugged box to make viable boxes down to 3U x 3U x 4U - v.0.5.1 - Increased the resolution of the gridfinity extruded base profile - v.0.5.2 - Adjusted geometry of box/bin floor/lip heights to exactly 7.00 mm intervals - v.0.5.3 - Removed a potential namespace collision for computing the height of boxes - v.0.5.4 - Optimized the geometry of the baseplate top height - v.0.5.5 - Added underside bin clearance and variable wall thickness interior radiusing - v.0.5.6 - Added adjustable magnet hole diameter to box. Prevent drawer spacers being rendered which fall below minimum size - v.0.5.7 - Added scoops to lite-style boxes. Added new "rib style" rugged box. Added a lid window feature to the rugged box. # References - [Zach Freedman's YouTube Channel](https://www.youtube.com/c/ZackFreedman) - [The video that started it all!](https://youtu.be/ra_9zU-mnl8?si=EOT1LFV65VZfiepi) - [Gridfinity Documentation repo](https://github.com/Stu142/Gridfinity-Documentation) - [Gridfinity Unofficial wiki](https://gridfinity.xyz) - Catalogs - [gridfinity-catalog](https://github.com/jeffbarr/gridfinity-catalog) - [Master Collection on Printables](https://www.printables.com/model/242711-gridfinity-master-collection) - Software/Tools - [Online Gridfinity Creator](https://gridfinity.bouwens.co) - [Gridfinity rebuilt OpenSCAD library](https://github.com/kennetek/gridfinity-rebuilt-openscad) - [Gridfinity Fusion360 generator plugin](https://github.com/Le0Michine/FusionGridfinityGenerator) - [FreeCAD Gridfinity Parametric Files (on Printables)](https://www.printables.com/@Stu142_524934/collections/969910) - [Gridfinity eco (low-cost Gridfinity resources)](https://github.com/jrymk/gridfinity-eco) - [Another CadQuery based Gridfinity script](https://github.com/kmeisthax/gridfinity-cadquery) - Videos - [Zach Freedman's follow-up Jul 2022](https://youtu.be/Bd4NnHvTRAY?si=rvgb9geXnq83mhOv) - [Zach Freedman's follow-up Dec 2022](https://youtu.be/7FCwMq-rVsY?si=tdqAe8MthGjfWEbR) - [The Next Layer tips video](https://youtu.be/KtbKwAuwv9s?si=1hYPjOvqf8tb5NO9) ## Authors **cq-gridfinity** was written by [Michael Gale](https://github.com/michaelgale) ================================================ FILE: cqgridfinity/__init__.py ================================================ """cqgridfinity - A python library to make Gridfinity compatible objects with CadQuery.""" import os # fmt: off __project__ = 'cqgridfinity' __version__ = '0.5.7' # fmt: on VERSION = __project__ + "-" + __version__ script_dir = os.path.dirname(__file__) from .constants import * from .gf_obj import GridfinityObject from .gf_baseplate import GridfinityBaseplate from .gf_box import GridfinityBox, GridfinitySolidBox from .gf_drawer import GridfinityDrawerSpacer from .gf_ruggedbox import GridfinityRuggedBox ================================================ FILE: cqgridfinity/constants.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Globally useful constants representing Gridfinity geometry from math import sqrt SQRT2 = sqrt(2) EPS = 1e-5 M2_DIAM = 1.8 M2_CLR_DIAM = 2.5 M3_DIAM = 3 M3_CLR_DIAM = 3.5 M3_CB_DIAM = 5.5 M3_CB_DEPTH = 3.5 GRU = 42 GRU2 = GRU / 2 GRHU = 7 GRU_CUT = 42.2 # base extrusion width GR_WALL = 1.0 # nominal exterior wall thickness GR_DIV_WALL = 1.2 # width of dividing walls GR_TOL = 0.5 # nominal tolerance GR_RAD = 4 # nominal exterior filleting radius GR_BASE_CLR = 0.25 # clearance above the nominal base height GR_BASE_HEIGHT = 4.75 # nominal base height # baseplate extrusion profile GR_BASE_CHAMF_H = 0.98994949 / SQRT2 GR_STR_H = 1.8 GR_BASE_TOP_CHAMF = GR_BASE_HEIGHT - GR_BASE_CHAMF_H - GR_STR_H GR_BASE_PROFILE = ( (GR_BASE_TOP_CHAMF * SQRT2, 45), GR_STR_H, (GR_BASE_CHAMF_H * SQRT2, 45), ) GR_STR_BASE_PROFILE = ( (GR_BASE_TOP_CHAMF * SQRT2, 45), GR_STR_H + GR_BASE_CHAMF_H, ) GR_BOT_H = 7 # bin nominal floor height GR_FILLET = 1.1 # inside filleting radius GR_FLOOR = GR_BOT_H - GR_BASE_HEIGHT # floor offset # box/bin extrusion profile GR_BOX_CHAMF_H = 1.1313708 / SQRT2 GR_BOX_TOP_CHAMF = GR_BASE_HEIGHT - GR_BOX_CHAMF_H - GR_STR_H + GR_BASE_CLR GR_BOX_PROFILE = ( (GR_BOX_TOP_CHAMF * SQRT2, 45), GR_STR_H, (GR_BOX_CHAMF_H * SQRT2, 45), ) # bin mating lip extrusion profile GR_UNDER_H = 1.6 GR_TOPSIDE_H = 1.2 GR_LIP_PROFILE = ( (GR_UNDER_H * SQRT2, 45), GR_TOPSIDE_H, (0.7 * SQRT2, -45), 1.8, (1.3 * SQRT2, -45), ) GR_LIP_H = 0 for h in GR_LIP_PROFILE: if isinstance(h, tuple): GR_LIP_H += h[0] / SQRT2 else: GR_LIP_H += h GR_NO_PROFILE = (GR_LIP_H,) # bottom hole nominal dimensions GR_HOLE_D = 6.5 GR_HOLE_H = 2.4 GR_BOLT_D = 3.0 GR_BOLT_H = 3.6 + GR_HOLE_H GR_HOLE_DIST = 26 / 2 GR_HOLE_SLICE = 0.25 # Rugged Box constant parameters GR_RBOX_WALL = 2.5 GR_RBOX_FLOOR = 1.2 GR_RBOX_CWALL = 10.0 GR_RBOX_CORNER_W = 56 GR_RBOX_BACK_L = 66 GR_RBOX_FRONT_L = 56 GR_RBOX_RAD = 3.745 GR_RBOX_CRAD = 14 GR_RBOX_CHAN_W = 20 GR_RBOX_CHAN_D = GR_RBOX_CWALL - GR_RBOX_WALL GR_RBOX_VCUT_D = 1 GR_CLASP_SLIDE_D = 39 GR_CLASP_SLIDE_W = 4 GR_RIB_W = 2 GR_RIB_L = 5 GR_RIB_GAP = 1 GR_RIB_H = 3.5 GR_RIB_SEP = 4 GR_RIB_CTR = 10 GR_REG_L = 5 GR_REG_W = 2.5 GR_REG_H = 2.5 GR_REG_R0 = 10.75 GR_REG_R1 = 8.25 GR_BREG_R0 = GR_REG_R0 + 0.25 GR_BREG_R1 = GR_REG_R1 - 0.25 GR_HANDLE_L1 = 12 GR_HANDLE_L2 = 28 GR_HANDLE_H = 7.5 GR_HANDLE_W = 5 GR_HANDLE_SEP = 12.5 GR_HANDLE_OFS = 61.5 GR_HANDLE_SZ = 30 GR_HANDLE_TH = 7 GR_HANDLE_RAD = 11 GR_LID_HANDLE_W = 70 GR_SIDE_HANDLE_W = 60 GR_HINGE_SZ = 32 GR_HINGE_D = 3 GR_HINGE_W1 = 5.5 GR_HINGE_H1 = 2.7 GR_HINGE_W2 = 2.1 GR_HINGE_H2 = 9 GR_HINGE_CTR = 30.625 GR_HINGE_W3 = 2 GR_HINGE_SEP = 1 GR_HINGE_OFFS = 2.65 GR_HINGE_SKEW = 0.15 GR_HINGE_RAD = 3.5 GR_HINGE_TOL = 0.4 GR_HEX_H = 3 GR_HEX_W = 4 GR_HEX_D = 1.3 GR_LID_WINDOW_H = 6.5 GR_LABEL_SLOT_TH = 2.5 GR_LABEL_TH = 0.8 GR_LABEL_H = 31 GR_LATCH_L = 32.5 GR_LATCH_W = 19.6 GR_LATCH_H = 7 GR_LATCH_IW = 14.75 GR_LATCH_IL = 5.2 ================================================ FILE: cqgridfinity/gf_baseplate.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Gridfinity Baseplates import cadquery as cq from cqgridfinity import * from cqkit.cq_helpers import ( rounded_rect_sketch, composite_from_pts, rotate_x, recentre, ) from cqkit import VerticalEdgeSelector, HasZCoordinateSelector class GridfinityBaseplate(GridfinityObject): """Gridfinity Baseplate This class represents a basic Gridfinity baseplate object. This baseplate more or less conforms to the original simple baseplate released by Zach Freedman. As such, it does not include features such as mounting holes, magnet holes, weight slots, etc. length_u - length in U (42 mm / U) width_u - width in U (42 mm / U) ext_depth - extrude bottom face by an extra amount in mm straight_bottom - remove bottom chamfer and replace with straight side corner_screws - add countersink mounting screws to the inside corners corner_tab_size - size of mounting screw corner tabs csk_hole - mounting screw hole diameter csk_diam - mounting screw countersink diameter csk_angle - mounting screw countersink angle """ def __init__(self, length_u, width_u, **kwargs): super().__init__() self.length_u = length_u self.width_u = width_u self.ext_depth = 0 self.straight_bottom = False self.corner_screws = False self.corner_tab_size = 21 self.csk_hole = 5.0 self.csk_diam = 10.0 self.csk_angle = 82 for k, v in kwargs.items(): if k in self.__dict__ and v is not None: self.__dict__[k] = v if self.corner_screws: self.ext_depth = max(self.ext_depth, 5.0) def _corner_pts(self): oxy = self.corner_tab_size / 2 return [ (i * (self.length / 2 - oxy), j * (self.width / 2 - oxy), 0) for i in (-1, 1) for j in (-1, 1) ] def render(self): profile = GR_BASE_PROFILE if not self.straight_bottom else GR_STR_BASE_PROFILE if self.ext_depth > 0: profile = [*profile, self.ext_depth] rc = self.extrude_profile( rounded_rect_sketch(GRU_CUT, GRU_CUT, GR_RAD), profile ) rc = rotate_x(rc, 180).translate((GRU2, GRU2, GR_BASE_HEIGHT + self.ext_depth)) rc = recentre(composite_from_pts(rc, self.grid_centres), "XY") r = ( cq.Workplane("XY") .rect(self.length, self.width) .extrude(GR_BASE_HEIGHT + self.ext_depth) .edges("|Z") .fillet(GR_RAD) .faces(">Z") .cut(rc) ) if self.corner_screws: rs = cq.Sketch().rect(self.corner_tab_size, self.corner_tab_size) rs = cq.Workplane("XY").placeSketch(rs).extrude(self.ext_depth) rs = rs.faces(">Z").cskHole( self.csk_hole, cskDiameter=self.csk_diam, cskAngle=self.csk_angle ) r = r.union(recentre(composite_from_pts(rs, self._corner_pts()), "XY")) bs = VerticalEdgeSelector(self.ext_depth) & HasZCoordinateSelector(0) r = r.edges(bs).fillet(GR_RAD) return r ================================================ FILE: cqgridfinity/gf_box.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Gridfinity Boxes import math import cadquery as cq from cqkit import HasZCoordinateSelector, VerticalEdgeSelector, FlatEdgeSelector from cqkit.cq_helpers import rounded_rect_sketch, composite_from_pts from cqgridfinity import * class GridfinityBox(GridfinityObject): """Gridfinity Box This class represents a Gridfinity compatible box module. As a minimum, this class is initialized with basic 3D unit dimensions for length, width, and height. length and width are multiples of 42 mm Gridfinity intervals and height represents multiples of 7 mm. Many box features can be enabled with attributes provided either as keywords or direct dotted access. These attributes include: - solid : renders the box without an interior, i.e. a solid block. This is useful for making custom Gridfinity modules by subtracting out shapes from the solid interior. Normally, the box is rendered solid up to its maximum size; however, the solid_ratio attribute can specify a solid fill of between 0.0 to 1.0, i.e. 0 to 100% fill. - holes : adds bottom mounting holes for magnets or screws - scoops : adds a radiused bottom edge to the interior to help fetch parts from the box - labels : adds a flat flange along each compartment for adding a label - no_lip : removes the contoured lip on the top module used for stacking - length_div, width_div : subdivides the box into sub-compartments in length and/or width. - lite_style : render box as an economical shell without elevated floor - unsupported_holes : render bottom holes as 3D printer friendly versions which can be printed without supports - label_width : width of top label ledge face overhang - label_height : height of label ledge overhang - scoop_rad : radius of the bottom scoop feature - wall_th : wall thickness - hole_diam : magnet/counterbore bolt hole diameter """ def __init__(self, length_u, width_u, height_u, **kwargs): super().__init__() self.length_u = length_u self.width_u = width_u self.height_u = height_u self.length_div = 0 self.width_div = 0 self.scoops = False self.labels = False self.solid = False self.holes = False self.no_lip = False self.solid_ratio = 1.0 self.lite_style = False self.unsupported_holes = False self.label_width = 12 # width of the label strip self.label_height = 10 # thickness of label overhang self.label_lip_height = 0.8 # thickness of label vertical lip self.scoop_rad = 14 # radius of optional interior scoops self.fillet_interior = True self.wall_th = GR_WALL self.hole_diam = GR_HOLE_D # magnet/bolt hole diameter for k, v in kwargs.items(): if k in self.__dict__: self.__dict__[k] = v self._int_shell = None self._ext_shell = None def __str__(self): s = [] s.append( "Gridfinity Box %dU x %dU x %dU (%.2f x %.2f x %.2f mm)" % ( self.length_u, self.width_u, self.height_u, self.length - GR_TOL, self.width - GR_TOL, self.height, ) ) sl = "no mating top lip" if self.no_lip else "with mating top lip" ss = "Lite style box " if self.lite_style else "" s.append(" %sWall thickness: %.2f mm %s" % (ss, self.wall_th, sl)) s.append( " Floor height : %.2f mm Inside height: %.2f mm Top reference height: %.2f mm" % (self.floor_h + GR_BASE_HEIGHT, self.int_height, self.top_ref_height) ) if self.solid: s.append(" Solid filled box with fill ratio %.2f" % (self.solid_ratio)) if self.holes: s.append(" Bottom mounting holes with %.2f mm diameter" % (self.hole_diam)) if self.unsupported_holes: s.append(" Holes are 3D printer friendly and can be unsupported") if self.scoops: s.append(" Lengthwise scoops with %.2f mm radius" % (self.scoop_rad)) if self.labels: s.append( " Lengthwise label shelf %.2f mm wide with %.2f mm overhang" % (self.label_width, self.label_height) ) if self.length_div: xl = (self.inner_l - GR_DIV_WALL * (self.length_div)) / ( self.length_div + 1 ) s.append( " %dx lengthwise divisions for %.2f mm compartment lengths" % (self.length_div, xl) ) if self.width_div: yl = (self.inner_w - GR_DIV_WALL * (self.width_div)) / (self.width_div + 1) s.append( " %dx widthwise divisions for %.2f mm compartment widths" % (self.width_div, yl) ) s.append(" Auto filename: %s" % (self.filename())) return "\n".join(s) def render(self): """Returns a CadQuery Workplane object representing this Gridfinity box.""" self._int_shell = None if self.lite_style: # just force the dividers to the desired quantity in both dimensions # rather than raise a exception if self.length_div: self.length_div = self.length_u - 1 if self.width_div: self.width_div = self.width_u - 1 if self.solid: raise ValueError( "Cannot select both solid and lite box styles together" ) if self.holes: raise ValueError( "Cannot select both holes and lite box styles together" ) if self.wall_th > 1.5: raise ValueError( "Wall thickness cannot exceed 1.5 mm for lite box style" ) if self.wall_th > 2.5: raise ValueError("Wall thickness cannot exceed 2.5 mm") if self.wall_th < 0.5: raise ValueError("Wall thickness must be at least 0.5 mm") r = self.render_shell() rd = self.render_dividers() rs = self.render_scoops() rl = self.render_labels() for e in (rd, rl, rs): if e is not None: r = r.union(e) if not self.solid and self.fillet_interior: heights = [GR_FLOOR] if self.labels: heights.append(self.safe_label_height(backwall=True, from_bottom=True)) heights.append(self.safe_label_height(backwall=False, from_bottom=True)) bs = ( HasZCoordinateSelector(heights, min_points=1, tolerance=0.5) + VerticalEdgeSelector(">5") - HasZCoordinateSelector("<%.2f" % (self.floor_h)) ) if self.lite_style and self.scoops: bs = bs - HasZCoordinateSelector("<=%.2f" % (self.floor_h)) bs = bs - VerticalEdgeSelector() r = self.safe_fillet(r, bs, self.safe_fillet_rad) if self.lite_style and not self.has_dividers: bs = FlatEdgeSelector(self.floor_h) if self.wall_th < 1.2: r = self.safe_fillet(r, bs, 0.5) elif self.wall_th < 1.25: r = self.safe_fillet(r, bs, 0.25) if not self.labels and self.has_dividers: bs = VerticalEdgeSelector( GR_TOPSIDE_H, tolerance=0.05 ) & HasZCoordinateSelector(GRHU * self.height_u - GR_BASE_HEIGHT) r = self.safe_fillet(r, bs, GR_TOPSIDE_H - EPS) if self.holes: r = self.render_holes(r) r = r.translate((-self.half_l, -self.half_w, GR_BASE_HEIGHT)) if self.unsupported_holes: r = self.render_hole_fillers(r) return r @property def top_ref_height(self): """The height of the top surface of a solid box or the floor height of an empty box.""" if self.solid: return self.max_height * self.solid_ratio + GR_BOT_H if self.lite_style: return self.floor_h return GR_BOT_H @property def bin_height(self): return self.height - GR_BASE_HEIGHT def safe_label_height(self, backwall=False, from_bottom=False): lw = self.label_width if backwall: lw += self.lip_width lh = self.label_height * (lw / self.label_width) yl = self.max_height - self.label_height + self.wall_th if backwall: yl -= self.lip_width if yl < 0: lh = self.max_height - 1.5 * GR_FILLET - 0.1 elif yl < 1.5 * GR_FILLET: lh -= 1.5 * GR_FILLET - yl + 0.1 if from_bottom: ws = math.sin(math.atan2(self.label_height, self.label_width)) if backwall: lh = self.max_height + GR_FLOOR - lh + ws * self.wall_th else: lh = self.max_height + GR_FLOOR - lh + ws * GR_DIV_WALL return lh @property def has_dividers(self): return self.length_div > 0 or self.width_div > 0 @property def interior_solid(self): if self._int_shell is not None: return self._int_shell self._int_shell = self.render_interior() return self._int_shell def render_interior(self, force_solid=False): """Renders the interior cutting solid of the box.""" wall_u = self.wall_th - GR_WALL wall_h = self.int_height + wall_u under_h = ((GR_UNDER_H - wall_u) * SQRT2, 45) profile = GR_NO_PROFILE if self.no_lip else [under_h, *GR_LIP_PROFILE[1:]] profile = [wall_h, *profile] if self.int_height < 0: profile = [self.height - GR_BOT_H] rci = self.extrude_profile( rounded_rect_sketch(*self.inner_dim, self.inner_rad), profile ) rci = rci.translate((*self.half_dim, self.floor_h)) if self.solid or force_solid: hs = self.max_height * self.solid_ratio ri = rounded_rect_sketch(*self.inner_dim, self.inner_rad) rf = cq.Workplane("XY").placeSketch(ri).extrude(hs) rf = rf.translate((*self.half_dim, self.floor_h)) rci = rci.cut(rf) if self.scoops and not self.no_lip and not self.lite_style: rf = ( cq.Workplane("XY") .rect(self.inner_l, 2 * self.under_h) .extrude(self.max_height) .translate((self.half_l, -self.half_in, self.floor_h)) ) rci = rci.cut(rf) if self.lite_style: r = composite_from_pts(self.base_interior(), self.grid_centres) rci = rci.union(r) return rci def solid_shell(self): """Returns a completely solid box object useful for intersecting with other solids.""" if self._ext_shell is not None: return self._ext_shell r = self.render_shell(as_solid=True) self._ext_shell = r.cut(self.render_interior(force_solid=True)) return self._ext_shell def mask_with_obj(self, obj): """Intersects a solid object with this box.""" return obj.intersect(self.solid_shell()) def base_interior(self): profile = [GR_BASE_HEIGHT, *GR_BOX_PROFILE] zo = GR_BASE_HEIGHT + GR_BASE_CLR if self.int_height < 0: h = self.bin_height - GR_BASE_HEIGHT profile = [h, *profile] zo += h r = self.extrude_profile( rounded_rect_sketch(GRU - GR_TOL, GRU - GR_TOL, self.outer_rad), profile, ) rx = r.faces(" 0 and not self.solid: wall_w = ( cq.Workplane("XY") .rect(GR_DIV_WALL, self.outer_w) .extrude(self.max_height) .translate((0, 0, self.floor_h)) ) xl = self.inner_l / (self.length_div + 1) pts = [ ((x + 1) * xl - self.half_in, self.half_w) for x in range(self.length_div) ] r = composite_from_pts(wall_w, pts) if self.width_div > 0 and not self.solid: wall_l = ( cq.Workplane("XY") .rect(self.outer_l, GR_DIV_WALL) .extrude(self.max_height) .translate((0, 0, self.floor_h)) ) yl = self.inner_w / (self.width_div + 1) pts = [ (self.half_l, (y + 1) * yl - self.half_in) for y in range(self.width_div) ] rw = composite_from_pts(wall_l, pts) if r is not None: r = r.union(rw) else: r = rw return r def render_scoops(self): if not self.scoops or self.solid: return None # front wall scoop # prevent the scoop radius exceeding the internal height srad = min(self.scoop_rad, self.int_height - 0.1) rs = cq.Sketch().rect(srad, srad).vertices(">X and >Y").circle(srad, mode="s") rsc = cq.Workplane("YZ").placeSketch(rs).extrude(self.inner_l) rsc = rsc.translate((0, 0, srad / 2 + GR_FLOOR)) yo = -self.half_in + srad / 2 # offset front wall scoop by top lip overhang if applicable if not self.no_lip and not self.lite_style: yo += self.under_h zo = -GR_BOT_H + self.wall_th if self.lite_style else 0 rs = rsc.translate((-self.half_in, yo, zo)) # intersect to prevent solids sticking out of rounded corners r = rs.intersect(self.interior_solid) if self.width_div > 0: # add scoops along each internal dividing wall in the width dimension yl = self.inner_w / (self.width_div + 1) pts = [ (-self.half_in, (y + 1) * yl - self.half_in) for y in range(self.width_div) ] rs = composite_from_pts(rsc, pts) r = r.union(rs.translate((0, GR_DIV_WALL / 2 + srad / 2, zo))) r = r.intersect(self.render_shell(as_solid=True)) return r def render_labels(self): if not self.labels or self.solid: return None # back wall label flange with compensated width and height lw = self.label_width + self.lip_width rs = ( cq.Sketch() .segment((0, 0), (lw, 0)) .segment((lw, -self.safe_label_height(backwall=True))) .segment((0, -self.label_lip_height)) .close() .assemble() .vertices(" 0: # add label flanges along each dividing wall rs = ( cq.Sketch() .segment((0, 0), (self.label_width, 0)) .segment((self.label_width, -self.safe_label_height(backwall=False))) .segment((0, -self.label_lip_height)) .close() .assemble() .vertices(" self.min_margin @property def deep_enough(self): return self.length_th > self.min_margin @property def fb_length_th(self): if not self.front_and_back: return 2 * self.length_th return self.length_th def check_dimensions(self): """Check required size does not fall below specified minimum margin.""" if not self.wide_enough and not self.deep_enough: print("Drawer spacers NOT required since resulting margins are:") print( " %.2f mm +/-%.2f mm (tolerance) widthwise which is not above the %.2f margin threshold" % (self.length_th, self.tolerance, self.min_margin) ) print( " %.2f mm +/-%.2f mm (tolerance) depthwise which is not above the %.2f margin threshold" % (self.width_th, self.tolerance, self.min_margin) ) return False return True def render(self, arrows_top=True, arrows_bottom=True, front_and_back=True): """Renders a corner spacer component. This component can be used for any of the four corners due to symmetry. Optional arrows can be cut into the component on the top or bottom to show the drawer sliding/depth-wise direction """ if not self.check_dimensions(): return None sp_length = self.length + self.width_th + self.tolerance sp_width = self.width + self.fb_length_th + self.tolerance r, rd = None, None if self.deep_enough and front_and_back: r = ( cq.Workplane("XY") .rect(sp_length, self.fb_length_th) .extrude(self.thickness) ) er = min(GR_RAD, max(self.length_th, self.width_th) / 4) r = r.translate((sp_length / 2, self.fb_length_th / 2, 0)) r = r.edges("|Z").edges("Z or self.align_min: rc = self.alignment_feature(as_cutter=True) r = r.cut(rc.translate((sp_length, self.fb_length_th / 2, 0))) if self.align_features and self.width_th > self.align_min: rc = self.alignment_feature(as_cutter=False, horz=False) r = r.union(rc.translate((self.width_th / 2, sp_width, 0))) self._cq_obj = r self._obj_label = "corner_spacer" return r def alignment_feature(self, as_cutter=False, horz=True): """Renders optional mating alignment pegs/holes for connecting the spacer components.""" x, y = self.align_l, self.fb_length_th / 2 if not horz: y = self.width_th / 2 fr = min(GR_RAD / 2, y / 3) if as_cutter: x += 2 * self.align_tol y += 2 * self.align_tol fr += self.align_tol rs = ( cq.Sketch() .segment((0, y / 3), (x / 2, y / 2)) .segment((x / 2, -y / 2)) .segment((0, -y / 3)) .segment((-x / 2, -y / 2)) .segment((-x / 2, y / 2)) .close() .assemble() .vertices() .fillet(fr) ) r = cq.Workplane("XY").placeSketch(rs).extrude(self.thickness) if not horz: r = rotate_z(r, 90) if not as_cutter: r = r.faces(">Z or Z or self.align_min: if alignment_type == "hole": ra = self.alignment_feature(as_cutter=True) r = r.cut(ra.translate((self.length_fill / 2, 0, 0))) r = r.cut(ra.translate((-self.length_fill / 2, 0, 0))) else: ra = self.alignment_feature(as_cutter=False) r = r.union(ra.translate((self.length_fill / 2, 0, 0))) r = r.union(ra.translate((-self.length_fill / 2, 0, 0))) self._cq_obj = r self._obj_label = "length_spacer" return r def render_width_filler(self, arrows_top=True, arrows_bottom=True): """Renders the centre filler element used along the left/right walls of the drawer.""" if not self.wide_enough: return None r = ( cq.Workplane("XY") .rect(self.width_th, self.width_fill) .extrude(self.thickness) ) r = r.edges("|Z").fillet(self.fillet_rad) r = r.faces(">Z or self.align_min: ra = self.alignment_feature(horz=False, as_cutter=True) r = r.cut(ra.translate((0, self.width_fill / 2, 0))) r = r.cut(ra.translate((0, -self.width_fill / 2, 0))) self._cq_obj = r self._obj_label = "width_spacer" return r def render_full_set(self, include_baseplate=False): """Renders a complete set of spacer components including the four corners plus left/right and front/back spacer pairs. The components are placed in their respective installed position in the drawer so that the resulting object can be used to preview final composition of components.""" # Four corners top/bottom left + top/bottom right if not self.check_dimensions(): return None if self.front_and_back: bl = self.render() tl = rotate_x(bl, 180).translate((0, self.size[1], self.thickness)) br = rotate_y(bl, 180).translate((self.size[0], 0, self.thickness)) tr = rotate_z(bl, 180).translate((*self.size, 0)) else: bl = self.render(arrows_bottom=False) br = self.render(arrows_top=False) br = rotate_y(br, 180).translate((self.size[0], 0, self.thickness)) tl = self.render(arrows_bottom=False, front_and_back=False) tl = rotate_z(tl, 180).translate((self.width_th, self.size[1], 0)) tr = self.render(arrows_top=False, front_and_back=False) tr = rotate_y(tr, 180) tr = rotate_z(tr, 180) tr = tr.translate((*self.size, 0)) tr = tr.translate((-self.width_th, 0, self.thickness)) r = bl.union(tl).union(br).union(tr) # 2x length-wise (drawer width) fillers if self.deep_enough: lf = self.render_length_filler() r = r.union(lf.translate((self.size[0] / 2, self.fb_length_th / 2, 0))) if self.front_and_back: r = r.union( lf.translate( (self.size[0] / 2, self.size[1] - self.fb_length_th / 2, 0) ) ) # 2x width-wise (drawer depth) fillers if self.wide_enough: wf = self.render_width_filler() yo = self.size[1] / 2 if not self.front_and_back: yo += self.fb_length_th / 2 r = r.union(wf.translate((self.width_th / 2, yo, 0))) r = r.union(wf.translate((self.size[0] - self.width_th / 2, yo, 0))) if include_baseplate: bp = GridfinityBaseplate(*self.size_u) rb = bp.render().translate((self.size[0] / 2, self.size[1] / 2, 0)) if not self.front_and_back: rb = rb.translate((0, self.fb_length_th / 2, 0)) r = r.union(rb) self._cq_obj = r self._obj_label = "full_set" return r def render_half_set(self): """Renders half of the full set of spacer components arranged for convenience for 3D printing. This resulting compound object can then be printed twice to yield a complete set of spacer components for a drawer. If front_and_back is False, then this function will render all of the components to fill the drawer since only one set of corner spacers is required and the remaining spacers are typically slim enough to fit together on a build plate.""" # one of each corner if not self.check_dimensions(): return None bl = self.render(arrows_bottom=False) br = self.render(arrows_top=False) if self.deep_enough: xo = self.length + 2.5 * self.width_th yo = 1.5 * self.fb_length_th else: xo = 2.5 * self.width_th yo = 0 br = rotate_y(br, 180).translate((xo, yo, self.thickness)) r = bl.union(br) # length-wise (drawer width) filler if self.deep_enough: xl = self.length_fill / 2 - ( self.length_fill - (self.length + self.width_th) ) if self.fb_length_th > self.align_min: xl -= self.align_l / 2 if self.wide_enough: yt = self.width + self.fb_length_th if self.width_th > self.align_min: yt += self.align_l / 2 yl = max(yt, self.width_fill) yl += max(self.fb_length_th, self.align_l / 2) else: yl = 3.5 * self.fb_length_th r = r.union(self.render_length_filler().translate((xl, yl, 0))) # width-wise (drawer depth) filler if self.wide_enough: wf = self.render_width_filler(arrows_bottom=False) r = r.union(wf.translate((-self.width_th, self.width_fill / 2, 0))) if not self.front_and_back: r = r.union( wf.translate((-2.5 * self.width_th, self.width_fill / 2, 0)) ) fb = self.render(arrows_bottom=False, front_and_back=False) r = r.union(fb.translate((-4.5 * self.width_th, 0, 0))) r = r.union(fb.translate((-6 * self.width_th, 0, 0))) self._cq_obj = r self._obj_label = "half_set" return r ================================================ FILE: cqgridfinity/gf_helpers.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Gridfinity Helper Functions import cadquery as cq from cqkit import rotate_z def quarter_circle( outer_rad, inner_rad, height, quad="tr", chamf=0.5, chamf_face=">Z", ext=0 ): """Renders a quarter circle shaped slot in any of 4 quadrants""" r = cq.Workplane("XY").circle(outer_rad).extrude(height) rc = cq.Workplane("XY").circle(inner_rad).extrude(height) r = r.cut(rc) rc = cq.Workplane("XY").rect(outer_rad, outer_rad).extrude(height) pos = { "tr": (outer_rad / 2, outer_rad / 2, 0), "tl": (-outer_rad / 2, outer_rad / 2, 0), "br": (outer_rad / 2, -outer_rad / 2, 0), "bl": (-outer_rad / 2, -outer_rad / 2, 0), } pt = pos[quad] r = r.intersect(rc.translate(pt)) r = r.translate((-pt[0], -pt[1], 0)) if ext > 0: faces = { "tl": "X", "tr": "Y", "bl": ">Y >X", } for face in faces[quad].split(): r = r.faces(face).wires().toPending().workplane().extrude(ext, combine=True) if chamf > 0: r = r.faces(chamf_face).chamfer(chamf) return r def chamf_cyl(rad, height, chamf=0.5): """Chamfered cylinder.""" r = cq.Workplane("XY").circle(rad).extrude(height) if chamf > 0: return r.faces("Z").chamfer(chamf) return r def chamf_rect(length, width, height, angle=0, tol=0.5, z_offset=0): """Chamfer rectangular box""" if not z_offset > 0: length += tol width += tol height += tol r = cq.Workplane("XY").rect(length, width).extrude(height) r = r.faces(">Z").chamfer(0.5).translate((0, 0, z_offset)) return rotate_z(r, angle) ================================================ FILE: cqgridfinity/gf_obj.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Gridfinity base object class import math import os from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.StlAPI import StlAPI_Writer import cadquery as cq from cadquery import exporters from cqgridfinity import * from cqkit import export_step_file # Special test to see which version of CadQuery is installed and # therefore if any compensation is required for extruded zlen # CQ versions < 2.4.0 typically require zlen correction, i.e. # scaling the vertical extrusion extent by 1/cos(taper) ZLEN_FIX = True _r = cq.Workplane("XY").rect(2, 2).extrude(1, taper=45) _bb = _r.vals()[0].BoundingBox() if abs(_bb.zlen - 1.0) < 1e-3: ZLEN_FIX = False class GridfinityObject: """Base Gridfinity object class This class bundles glabally relevant constants, properties, and methods for derived Gridfinity object classes. """ def __init__(self, **kwargs): self.length_u = 1 self.width_u = 1 self.height_u = 1 self._cq_obj = None self._obj_label = None for k, v in kwargs.items(): if k in self.__dict__: self.__dict__[k] = v @property def cq_obj(self): if self._cq_obj is None: return self.render() return self._cq_obj @property def length(self): return self.length_u * GRU @property def width(self): return self.width_u * GRU @property def height(self): return 3.8 + GRHU * self.height_u @property def int_height(self): h = self.height - GR_LIP_H - GR_BOT_H if self.lite_style: return h + self.wall_th return h @property def max_height(self): return self.int_height + GR_UNDER_H + GR_TOPSIDE_H @property def floor_h(self): if self.lite_style: return GR_FLOOR - self.wall_th return GR_FLOOR @property def lip_width(self): if self.no_lip: return self.wall_th return GR_UNDER_H + self.wall_th @property def outer_l(self): return self.length_u * GRU - GR_TOL @property def outer_w(self): return self.width_u * GRU - GR_TOL @property def outer_dim(self): return self.outer_l, self.outer_w @property def inner_l(self): return self.outer_l - 2 * self.wall_th @property def inner_w(self): return self.outer_w - 2 * self.wall_th @property def inner_dim(self): return self.inner_l, self.inner_w @property def half_l(self): return (self.length_u - 1) * GRU2 @property def half_w(self): return (self.width_u - 1) * GRU2 @property def half_dim(self): return self.half_l, self.half_w @property def half_in(self): return GRU2 - self.wall_th - GR_TOL / 2 @property def outer_rad(self): return GR_RAD - GR_TOL / 2 @property def inner_rad(self): return self.outer_rad - self.wall_th @property def under_h(self): return GR_UNDER_H - (self.wall_th - GR_WALL) @property def safe_fillet_rad(self): if not any([self.scoops, self.labels, self.length_div, self.width_div]): return GR_FILLET return min(GR_FILLET, (GR_UNDER_H + GR_WALL) - self.wall_th - 0.05) @property def grid_centres(self): return [ (x * GRU, y * GRU) for x in range(self.length_u) for y in range(self.width_u) ] @property def hole_centres(self): return [ (x * GRU - GR_HOLE_DIST * i, -(y * GRU - GR_HOLE_DIST * j)) for x in range(self.length_u) for y in range(self.width_u) for i in (-1, 1) for j in (-1, 1) ] def safe_fillet(self, obj, selector, rad): if len(obj.edges(selector).vals()) > 0: return obj.edges(selector).fillet(rad) return obj def filename(self, prefix=None, path=None): """Returns a descriptive readable filename which represents a Gridfinity object. The filename can be optionally prefixed with arbitrary text and an optional path prefix can also be specified.""" from cqgridfinity import ( GridfinityBaseplate, GridfinityBox, GridfinityDrawerSpacer, GridfinityRuggedBox, ) if prefix is not None: prefix = prefix elif isinstance(self, GridfinityBaseplate): prefix = "gf_baseplate_" elif isinstance(self, GridfinityBox): prefix = "gf_box_" if self.lite_style: prefix = prefix + "lite_" elif isinstance(self, GridfinityDrawerSpacer): prefix = "gf_drawer_" elif isinstance(self, GridfinityRuggedBox): prefix = "gf_ribbox_" if self.rib_style else "gf_ruggedbox_" else: prefix = "" fn = "" if path is not None: fn = fn.replace(os.sep, "") fn = path + os.sep fn = fn + prefix fn = fn + "%dx%d" % (self.length_u, self.width_u) if isinstance(self, GridfinityBox): fn = fn + "x%d" % (self.height_u) if self.length_div and not self.solid: fn = fn + "_div%d" % (self.length_div) if self.width_div and not self.solid: if self.length_div: fn = fn + "x%d" % (self.width_div) else: fn = fn + "_div_x%d" % (self.width_div) if abs(self.wall_th - GR_WALL) > 1e-3: fn = fn + "_%.2f" % (self.wall_th) if self.no_lip: fn = fn + "_basic" if self.holes: fn = fn + "_holes" if self.solid: fn = fn + "_solid" else: if self.scoops: fn = fn + "_scoops" if self.labels: fn = fn + "_labels" elif isinstance(self, GridfinityRuggedBox): fn = fn + "x%d" % (self.height_u) if self._obj_label is not None: fn = fn + "_%s" % (self._obj_label) if self.front_handle or self.front_label: fn = fn + "_fr-" if self.front_handle: fn = fn + "h" if self.front_label: fn = fn + "l" if self.side_handles or self.side_clasps: fn = fn + "_sd-" if self.side_handles: fn = fn + "h" if self.side_clasps: fn = fn + "c" if self.stackable: fn = fn + "_stack" if self.lid_baseplate: fn = fn + "_lidbp" if self.lid_window: fn = fn + "_win" elif isinstance(self, GridfinityDrawerSpacer): if self._obj_label is not None: fn = fn + "_%s" % (self._obj_label) elif isinstance(self, GridfinityBaseplate): if self.ext_depth > 0: fn = fn + "x%.1f" % (self.ext_depth) if self.corner_screws: fn = fn + "_screwtabs" return fn def save_step_file(self, filename=None, path=None, prefix=None): fn = ( filename if filename is not None else self.filename(path=path, prefix=prefix) ) if not fn.lower().endswith(".step"): fn = fn + ".step" if isinstance(self.cq_obj, cq.Assembly): self.cq_obj.save(fn) else: export_step_file(self.cq_obj, fn) def save_stl_file( self, filename=None, path=None, prefix=None, tol=1e-2, ang_tol=0.1 ): fn = ( filename if filename is not None else self.filename(path=path, prefix=prefix) ) if not fn.lower().endswith(".stl"): fn = fn + ".stl" obj = self.cq_obj.val().wrapped mesh = BRepMesh_IncrementalMesh(obj, tol, True, ang_tol, True) mesh.Perform() writer = StlAPI_Writer() writer.Write(obj, fn) def save_svg_file(self, filename=None, path=None, prefix=None): fn = ( filename if filename is not None else self.filename(path=path, prefix=prefix) ) if not fn.lower().endswith(".svg"): fn = fn + ".svg" r = self.cq_obj.rotate((0, 0, 0), (0, 0, 1), 75) r = r.rotate((0, 0, 0), (1, 0, 0), -90) exporters.export( r, fn, opt={ "width": 600, "height": 400, "showAxes": False, "marginTop": 20, "marginLeft": 20, "projectionDir": (1, 1, 1), }, ) def extrude_profile(self, sketch, profile, workplane="XY", angle=None): taper = profile[0][1] if isinstance(profile[0], (list, tuple)) else 0 zlen = profile[0][0] if isinstance(profile[0], (list, tuple)) else profile[0] if abs(taper) > 0: if angle is None: zlen = zlen if ZLEN_FIX else zlen / SQRT2 else: zlen = zlen / math.cos(math.radians(taper)) if ZLEN_FIX else zlen r = cq.Workplane(workplane).placeSketch(sketch).extrude(zlen, taper=taper) for level in profile[1:]: if isinstance(level, (tuple, list)): if angle is None: zlen = level[0] if ZLEN_FIX else level[0] / SQRT2 else: zlen = ( level[0] / math.cos(math.radians(level[1])) if ZLEN_FIX else level[0] ) r = r.faces(">Z").wires().toPending().extrude(zlen, taper=level[1]) else: r = r.faces(">Z").wires().toPending().extrude(level) return r @classmethod def to_step_file( cls, length_u, width_u, height_u=None, filename=None, prefix=None, path=None, **kwargs ): """Convenience method to create, render and save a STEP file representation of a Gridfinity object.""" obj = GridfinityObject.as_obj(cls, length_u, width_u, height_u, **kwargs) obj.save_step_file(filename=filename, path=path, prefix=prefix) @classmethod def to_stl_file( cls, length_u, width_u, height_u=None, filename=None, prefix=None, path=None, **kwargs ): """Convenience method to create, render and save a STEP file representation of a Gridfinity object.""" obj = GridfinityObject.as_obj(cls, length_u, width_u, height_u, **kwargs) obj.save_stl_file(filename=filename, path=path, prefix=prefix) @staticmethod def as_obj(cls, length_u=None, width_u=None, height_u=None, **kwargs): if "GridfinityBox" in cls.__name__: obj = GridfinityBox(length_u, width_u, height_u, **kwargs) if "GridfinitySolidBox" in cls.__name__: obj.solid = True elif "GridfinityBaseplate" in cls.__name__: obj = GridfinityBaseplate(length_u, width_u, **kwargs) elif "GridfinityDrawerSpacer" in cls.__name__: obj = GridfinityDrawerSpacer(**kwargs) return obj ================================================ FILE: cqgridfinity/gf_ruggedbox.py ================================================ #! /usr/bin/env python3 # # Copyright (C) 2023 Michael Gale # This file is part of the cq-gridfinity python module. # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Gridfinity Rugged Boxes import math import cadquery as cq from cadquery.selectors import StringSyntaxSelector from cqkit import ( HasXCoordinateSelector, HasYCoordinateSelector, HasZCoordinateSelector, VerticalEdgeSelector, EdgeLengthSelector, RadiusSelector, FlatEdgeSelector, rounded_rect_sketch, recentre, composite_from_pts, rotate_x, rotate_y, rotate_z, size_2d, size_3d, bounds_3d, inverse_fillet, inverse_chamfer, Ribbon, ) # from cqkit import Ribbon from cqgridfinity import * from .gf_helpers import * class GridfinityRuggedBox(GridfinityObject): def __init__(self, length_u, width_u, height_u, **kwargs): super().__init__() self.length_u = length_u self.width_u = width_u self.height_u = height_u self.lid_height = 10 self.wall_vgrooves = True self.front_handle = True self.stackable = True self.side_clasps = True self.lid_baseplate = True self.inside_baseplate = True self.side_handles = True self.front_label = True self.label_length = None self.label_height = None self.label_th = GR_LABEL_TH self.back_feet = True self.hinge_width = GR_HINGE_SZ self.hinge_bolted = False self.rib_style = False self._lid_window = False self.window_th = 1.0 self.box_color = cq.Color(0.25, 0.25, 0.25) self.lid_color = cq.Color(0.25, 0.5, 0.75) self.handle_color = cq.Color(0.75, 0.5, 0.25) self.latch_color = cq.Color(0.75, 0.5, 0.25) self.hinge_color = cq.Color(0.75, 0.5, 0.25) self.label_color = cq.Color(0.7, 0.7, 0.7) self.window_color = cq.Color(0.9, 0.9, 0.9, 0.25) for k, v in kwargs.items(): if k in self.__dict__: self.__dict__[k] = v def check_dimensions(self): """Verifies that the specified box dimensions are within specification.""" assert self.length_u >= 3 assert self.width_u >= 3 assert self.height_u >= 4 @property def box_length(self): return self.length_u * GRU + 2 * GR_RBOX_WALL @property def int_length(self): return self.length_u * GRU @property def box_width(self): return self.width_u * GRU + 2 * GR_RBOX_WALL @property def int_width(self): return self.width_u * GRU @property def clasp_pos(self): return self.int_length / 2 - GRU2, self.int_width / 2 - GRU2 @property def box_height(self): return self.height_u * GRHU + 3 @property def clasp_heights(self): h0 = GR_RIB_CTR / 2 + GR_RIB_L / 2 h1 = h0 + GR_RIB_CTR return [GR_RIB_L / 2, self.box_height - h0, self.box_height - h1] @property def side_clasp_centres(self): xo = self.box_length / 2 + GR_RBOX_CHAN_D / 2 yo = self.clasp_pos[1] return [(-xo, yo, 0), (xo, yo, 0), (-xo, -yo, 0), (xo, -yo, 0)] @property def front_clasp_centres(self): xo = self.clasp_pos[0] yo = self.box_width / 2 + GR_RBOX_CHAN_D / 2 return [(-xo, -yo, 0), (xo, -yo, 0)] @property def clasp_notch_points(self): return [ ( x * GR_RBOX_CHAN_W / 2, -GR_RBOX_CHAN_D / 2, self.box_height - self.lid_height, ) for x in (-1, 1) ] @property def hinge_centres(self): xo = self.box_length / 2 - GR_HINGE_CTR yo = self.box_width / 2 + GR_RBOX_CWALL - GR_RBOX_WALL zo = self.box_height return [(-xo, yo, zo), (xo, yo, zo)] @property def align_centres(self): ro = GR_RBOX_CHAN_D / 2 - GR_REG_W / 2 xo, xc = self.box_length / 2 - GRU, self.box_length / 2 + ro yo, yc = self.box_width / 2 - GRU, self.box_width / 2 + ro pts = [ (-xo, -yc, 0), (xo, -yc, 0), (-xc, -yo, 0), (xc, -yo, 0), (-xc, yo, 0), (xc, yo, 0), ] rots = [0, 0, 90, 90, 90, 90] return pts, rots @property def right_qtr_centre(self): return ( self.box_length / 2 - GR_RBOX_WALL / 2 + 0.125, -self.box_width / 2 + GR_RBOX_WALL / 2 - 0.125, self.box_height, ) @property def left_qtr_centre(self): return -self.right_qtr_centre[0], *self.right_qtr_centre[1:] @property def bottom_qtr_centres(self): return self.qtr_centres(tol=0.25) def qtr_centres(self, tol=0.25, at_height=0, front=True, back=True): xo = self.box_length / 2 - GR_RBOX_WALL / 2 + tol yo = self.box_width / 2 - GR_RBOX_WALL / 2 + tol qd = {} if front: qd["br"] = (xo, -yo, at_height) qd["bl"] = (-xo, -yo, at_height) if back: qd["tr"] = (xo, yo, at_height) qd["tl"] = (-xo, yo, at_height) return qd @property def long_enough_for_handle(self): return self.right_handle_centre[0] > GRU / 2 @property def right_handle_centre(self): zo = (self.box_height + self.lid_height) / 2 if (zo + GR_HANDLE_SZ / 2) > self.box_height: zo = self.box_height / 2 return self.box_length / 2 - GR_HANDLE_OFS, -self.box_width / 2, zo @property def left_handle_centre(self): return -self.right_handle_centre[0], *self.right_handle_centre[1:] @property def back_corner_centres(self): xo = self.box_length / 2 - GR_RBOX_BACK_L / 2 + GR_RBOX_CWALL - GR_RBOX_WALL yo = self.box_width / 2 - GR_RBOX_CORNER_W / 2 + GR_RBOX_CWALL - GR_RBOX_WALL return [(-xo, yo, 0), (xo, yo, 0)] @property def front_corner_centres(self): xo = self.box_length / 2 - GR_RBOX_FRONT_L / 2 + GR_RBOX_CWALL - GR_RBOX_WALL yo = -self.back_corner_centres[0][1] return [(-xo, yo, 0), (xo, yo, 0)] @property def label_centre(self): zo = self.left_handle_centre[2] zt = zo + self.label_size()[1] / 2 # ensure the front label fits vertically if zt > self.box_height: zo = self.box_height / 2 return (0, -self.box_width / 2, zo) @property def lid_window(self): return self._lid_window @lid_window.setter def lid_window(self, enable): self._lid_window = enable if self._lid_window: self.lid_baseplate = False def lid_window_size(self, width_ext=None, tol=None): tol = tol if tol is not None else GR_TOL width_ext = width_ext if width_ext is not None else 4 return self.length - 2 - tol, self.width + width_ext - tol def lid_window_hole_pos(self, z=0): pts = [ (-x * (self.box_length / 2 - GR_RBOX_CORNER_W), self.width / 2 + 2, z) for x in (-1, 1) ] if self.rib_style: pts.append((0, self.width / 2 + 2, z)) return pts def label_size(self, as_insert=False, as_aperture=False, tol=0): # use provided label size if applicable otherwise auto size if self.label_length is not None: length = self.label_length else: length = self.box_length - 2 * GR_RBOX_CORNER_W + (GR_RBOX_CWALL) / 2 if self.label_height is not None: height = self.label_height else: height = GR_LABEL_H # ensure the label is not too tall if height >= self.box_height: height = self.box_height - 5 # trim label size if handles are enabled if self.front_handle and self.long_enough_for_handle: length = length - 2 * (GR_HANDLE_SEP + GR_HANDLE_W) # return the desired size variant if as_insert: length -= 5 if as_aperture: length -= 8 height -= 8 return length - 2 * tol, height - 2 * tol def body_shell(self, as_lid=False): """General purpose render function for both the box and the lid.""" height = self.box_height if not as_lid else self.lid_height # render overall box shape rs = rounded_rect_sketch(self.box_length, self.box_width, GR_RAD) r = cq.Workplane("XY").placeSketch(rs).extrude(height) # back corners if self.rib_style: lb = self.box_length + 2 * (GR_RBOX_CWALL - GR_RBOX_WALL) yo = self.back_corner_centres[0] rc = cq.Workplane("XY").rect(lb, GR_RBOX_CORNER_W).extrude(height) r = r.union(rc.translate((0, yo[1], 0))) if not as_lid or (as_lid and not self.side_handles): h = height / 2 if self.side_handles else height wb = self.box_width - GR_RBOX_CORNER_W rc = cq.Workplane("XY").rect(lb, wb).extrude(h) r = r.union(rc) else: rc = ( cq.Workplane("XY") .rect(GR_RBOX_BACK_L, GR_RBOX_CORNER_W) .extrude(height) ) r = r.union(composite_from_pts(rc, self.back_corner_centres)) # front corners rc = cq.Workplane("XY").rect(GR_RBOX_FRONT_L, GR_RBOX_CORNER_W).extrude(height) r = r.union(composite_from_pts(rc, self.front_corner_centres)) # fillet external edges vs = VerticalEdgeSelector() cs = StringSyntaxSelector("(X and Y) or (>XY)") r = r.edges(vs - cs).fillet(GR_RBOX_RAD).edges(cs).fillet(GR_RBOX_CRAD) if self.stackable or as_lid: # bottom stacking mates for k, v in self.qtr_centres(back=not as_lid).items(): rq = quarter_circle( GR_BREG_R0, GR_BREG_R1, GR_REG_H + 0.5, k, chamf=0, ext=0.25 ) r = r.cut(rq.translate(v)) pts, rots = self.align_centres for pt, rot in zip(pts, rots): rc = chamf_rect(GR_REG_L, GR_REG_W, GR_REG_H, angle=rot) r = r.cut(rc.translate(pt)) # chamfer top edges r = r.edges(">Z").chamfer(GR_RBOX_VCUT_D) # front lid overhang if as_lid: w = min(GR_LID_HANDLE_W, self.box_length - 2 * GR_RBOX_FRONT_L) r = r.union(self.lid_handle(width=w).translate((0, -self.box_width / 2, 0))) hw = w / 2 vs = VerticalEdgeSelector([9]) & HasXCoordinateSelector([-hw, hw]) r = r.edges(vs).fillet(2.5 - EPS) # chamfer cuts if self.wall_vgrooves: if self.rib_style: r = r.cut(self.render_vcut()) else: r = r.intersect(self.render_vcut()) # chamfer bottom edges r = r.edges("Y", 5, (StringSyntaxSelector("4")).chamfer(0.5)) r = r.faces("=0") ) r = r.edges(bs).chamfer(0.5).translate((-hw, 0, -2)) r = r.union(rh[2].translate((-hw, 0, -2))) if width > GR_LID_HANDLE_W / 2: r = r.union(rh[0].translate((-l2, 0, -2))) r = r.union(rh[1].translate((hw - GR_RBOX_WALL, 0, -2))) vs = VerticalEdgeSelector([h1 - 0.5]) & HasXCoordinateSelector([-hw, hw]) r = r.edges(vs).fillet(2) vs = VerticalEdgeSelector(2.9) & HasYCoordinateSelector(-l1 + GR_RBOX_WALL) r = r.edges(vs).fillet(1) rc = cq.Workplane("XY").rect(4 * hw, 4 * hw).extrude(self.lid_height + 2 * h2) r = r.intersect(rc.translate((0, 0, -2 * h2))) return r def label_slot(self): """Renders the front label holder.""" rs = rounded_rect_sketch(*self.label_size(), GR_RAD) r = self.extrude_profile(rs, [(GR_LABEL_SLOT_TH * SQRT2, 45)], workplane="XZ") rc = ( cq.Workplane("XZ") .rect(*self.label_size(as_aperture=True)) .extrude(GR_LABEL_SLOT_TH) ) r = r.cut(rc.edges(EdgeLengthSelector(GR_LABEL_SLOT_TH)).chamfer(2.5)) xl, yl = self.label_size(as_insert=True) xl -= 8 rc = cq.Workplane("XZ").rect(xl, yl).extrude(GR_LABEL_SLOT_TH) r = r.cut(rc.translate((0, 0, 5))) rc = ( cq.Workplane("XZ") .rect(*self.label_size(as_insert=True)) .extrude(GR_LABEL_SLOT_TH / 2) ) rc = rc.edges("|Y and Z").edges("X").chamfer(1.0) if chamfered: rc = ( cq.Workplane("XZ") .moveTo(0, 0) .lineTo(0, GR_RIB_H) .lineTo(GR_RIB_L / 6, GR_RIB_H) .close() .extrude(GR_RIB_W) ) r = r.cut(rc.translate((-GR_RIB_L / 1.85, GR_RIB_W / 2, 0))) rc = cq.Workplane("XY").rect(GR_RIB_L / 2, GR_RIB_W).extrude(GR_RIB_H / 3) rc = rc.faces(">Z").edges("X").chamfer(GR_RIB_H / 3 - EPS) r = r.union(rc.translate((-GR_RIB_L / 2.33, 0, 0))) return r def clasp_ribs(self, side="left", as_lid=False): """Renders a group of clasp ribs for any side for both the box and lid.""" y1 = GR_RIB_SEP / 2 + GR_RIB_W / 2 y2 = y1 + GR_RIB_W + GR_RIB_GAP zo = -GR_RBOX_CHAN_D / 2 pts = [(0, -y2, zo), (0, -y1, zo), (0, y1, zo), (0, y2, zo)] rh = composite_from_pts(self.clasp_rib(), pts) rc = composite_from_pts(self.clasp_rib(chamfered=True), pts) if self.stackable or as_lid: r = rh.translate((self.clasp_heights[0], 0, 0)) if not as_lid: rc = composite_from_pts(rc, [(h, 0, 0) for h in self.clasp_heights[1:]]) if not self.stackable: r = rc else: r = r.union(rc) r = rotate_y(r, -90) if side == "front": r = rotate_z(r, 90) elif side == "right": r = rotate_z(r, 180) return r def handle_mount(self, side="left"): """Mounting features for front handle""" def _bracket(small_hole=False, side="left"): l1 = GR_HANDLE_L1 / 2 l2 = min(GR_HANDLE_L2 / 2, (self.box_height - 6) / 2) d2 = M3_DIAM / 2 if small_hole else M3_CLR_DIAM / 2 rs = ( cq.Sketch() .segment((0, 0), (-l2, 0)) .segment((-l1, GR_HANDLE_H)) .segment((l1, GR_HANDLE_H)) .segment((l2, 0)) .close() .assemble() .vertices(">Y") .vertices("X") .fillet(GR_RAD) .reset() .push([(0, GR_HANDLE_H / 2)]) .circle(d2, mode="s") ) r = cq.Workplane("YZ").placeSketch(rs).extrude(GR_HANDLE_W) if not small_hole: face = ">X" if side == "left" else "Z").chamfer(0.75) return rotate_x(r, 90) h1 = _bracket(small_hole=True, side=side) h2 = _bracket(small_hole=False, side=side) xo = GR_HANDLE_SEP if side == "left" else -GR_HANDLE_SEP r = recentre(h1.union(h2.translate((xo, 0, 0))), "xz") return r def render_handle(self): """Renders the front handle""" self.check_dimensions() x2 = self.right_handle_centre[0] if not self.long_enough_for_handle: print("Rugged box length dimension too small to include a handle") return None wt, h, rh = GR_HANDLE_TH, GR_HANDLE_SZ, GR_HANDLE_RAD lt, ht = (2 * x2) - 2 * rh, h - rh - wt / 2 path = { "start": "(%f,%f) dir:-90 width:%f" % (x2, h, wt), "path": "L:%f A:%f,90 L:%f A:%f,90 L:%f" % (ht, rh, lt, rh, ht), } cw = Ribbon("XZ", path) cw.direction = -90 r = cw.render().extrude(wt).faces(">Z").edges("|X").fillet(wt / 2 - EPS) r = recentre(r.edges().chamfer(1), "XY") rc = cq.Workplane("YZ").circle(M3_CLR_DIAM / 2).extrude(8 * lt) r = r.cut(rc.translate((-4 * lt, 0, h - M3_CLR_DIAM))) self._obj_label = "handle" self._cq_obj = r return self._cq_obj def render_back_foot(self): """Renders a corresponding rear foot the same depth as the hinge for standing the box vertically.""" rs = cq.Sketch().slot(2 * GR_HINGE_OFFS, 2 * GR_HINGE_RAD, 0) rc = cq.Workplane("YZ").placeSketch(rs).extrude(self.hinge_width - 0.4) return recentre(rc).edges().chamfer(1).translate((0, 0, GR_HINGE_RAD)) def hinge_mount(self): """Mounting cutout for hinge""" l1, l2, l3 = self.hinge_width + 2, self.hinge_width, (self.hinge_width - 2) / 2 r = cq.Workplane("XY").rect(l1, GR_HINGE_W1).extrude(GR_HINGE_H1) r = r.translate((0, -GR_HINGE_W1 / 2, -GR_HINGE_H1)) r2 = cq.Workplane("XY").rect(l2, GR_HINGE_W2).extrude(GR_HINGE_H2) r2 = r2.translate((0, -GR_HINGE_D - GR_HINGE_W2 / 2, -GR_HINGE_H2)) bs = HasZCoordinateSelector(-GR_HINGE_H1) & EdgeLengthSelector( [l2, GR_HINGE_W2] ) r = r.union(r2).edges(bs).edges(">Y or X").chamfer(0.75) rs = rounded_rect_sketch(l3, GR_HINGE_W3, 0.5) r3 = cq.Workplane("XY").placeSketch(rs).extrude(GR_HINGE_H2) xo, yo = GR_HINGE_SEP / 2 + l3 / 2, -GR_HINGE_W1 - 1.2 - GR_HINGE_W3 / 2 rh = self.hex_cut().translate( (0, 0, GR_HINGE_H2 - GR_HINGE_H1 - GR_HEX_H / 2 + GR_HINGE_SKEW) ) for pt in [(-xo, yo, -GR_HINGE_H2), (xo, yo, -GR_HINGE_H2)]: r = r.union(r3.translate(pt)) r = r.union(rh.translate(pt)) return r def hex_cut(self, depth=None): """Hexagonal shaped latch for hinge attachment""" l1 = 2 if depth is None else 1.7 l2 = 3.5 if depth is None else 3.0 d = depth if depth is not None else 4.0 h = GR_HEX_H if depth is None else GR_HEX_H - 0.4 rs = ( cq.Sketch() .segment((0, 0), (-l1, 0)) .segment((-l2, h / 2)) .segment((-l1, h)) .segment((l1, h)) .segment((l2, h / 2)) .segment((l1, 0)) .close() .assemble() ) r = cq.Workplane("XZ").placeSketch(rs).extrude(d).translate((0, d, -h / 2)) if depth is not None: r = r.edges("Y").chamfer(depth - EPS) return r def render_latch(self): """Renders the latch element used to secure the box and the lid.""" l2, w2, h2 = GR_LATCH_L / 2, GR_LATCH_W / 2, GR_LATCH_H / 2 c2, th = GR_RIB_CTR / 2, 2.5 hf = GR_LATCH_H - th yc = (-1.575, 1.575) r = cq.Workplane("XY").rect(GR_LATCH_L, GR_LATCH_W).extrude(GR_LATCH_H) r = r.edges("|Y").edges(">X").chamfer(1.0) rs = cq.Sketch().slot(10, GR_LATCH_H, 0) rc = cq.Workplane("XZ").placeSketch(rs).extrude(GR_LATCH_W) r = r.union(rc.translate((-l2 + 4.5, w2, h2))) rc = cq.Workplane("XY").rect(16, 15.6).extrude(10).edges("|Z").fillet(4.0) r = r.cut(rc.translate((-l2 - 8, 0, 0))) rc = cq.Workplane("XY").rect(5, GR_LATCH_W - 2.4).extrude(10) rc = rc.faces("Z").edges(EdgeLengthSelector(GR_LATCH_IW)).chamfer(1.5) r = r.faces(">Z").edges(EdgeLengthSelector(GR_LATCH_IL)).chamfer(0.25) rc = cq.Workplane("XY").rect(20, 2.4).extrude(hf) r = r.cut(rc.translate((0, 0, th))) r = r.faces(">Z").edges(EdgeLengthSelector(1.8)).edges("|X").chamfer(0.25) rc = cq.Workplane("XY").rect(8.5, 0.75).extrude(4.5) rc = rc.faces(">Z").edges("|Y").chamfer(1.5) bs = EdgeLengthSelector(">0.8") - HasZCoordinateSelector(0, min_points=2) rc = rc.edges(bs).chamfer(0.2) (_, _, _), (xm, _, _) = bounds_3d(r) for pt in [(x - 1.25, y, th) for x in (-c2, c2) for y in yc]: r = r.union(rc.translate(pt)) rd = cq.Workplane("XY").rect(3.5, 1).extrude(7) for x, xo in [(-xm, 2.25), (13.75, -2.25)]: rx = rc.intersect(rd.translate((xo, 0, 0))) for pt in [(x, y, th) for y in yc]: r = r.union(rx.translate(pt)) rc = cq.Workplane("XZ").rect(2, 3.2).extrude(0.6).edges("X and Y").edges(EdgeLengthSelector(wh)).chamfer(1.5) return r rl = _bracket(side="left") for pt in [0, hc]: rl = rl.cut(chamf_cyl(cro, hb, 0).translate((*ctr, pt))) rr = _bracket(side="right") rr = rr.cut(chamf_cyl(cro, hd - ha, 0).translate((*ctr, ha))) bs = EdgeLengthSelector(">0.2") - EdgeLengthSelector([wh, h], tolerance=0.02) bs = bs - HasYCoordinateSelector(dh - 1.5, min_points=2) bs = bs - (RadiusSelector(cro) & HasZCoordinateSelector([ha, hb, hc, hd])) rl = rl.edges(bs).chamfer(0.5) rr = rr.edges(bs).chamfer(0.5) rl = rl.union(chamf_cyl(cri, hc - hb).translate((*ctr, hb))) if not self.hinge_bolted: rl = rl.cut(chamf_cyl(crb, hc - hb, 0).translate((*ctr, hb))) for pt in [0, hd]: rr = rr.union(chamf_cyl(cri, ha).translate((*ctr, pt))) if not self.hinge_bolted: rr = rr.union(chamf_cyl(crs, h, 0).translate((*ctr, 0))) else: rr = rr.cut(chamf_cyl(M3_DIAM / 2, h, 0).translate((*ctr, 0))) rl = rl.cut(chamf_cyl(M3_CLR_DIAM / 2, h, 0).translate((*ctr, 0))) rr = rr.cut(chamf_cyl(M3_CLR_DIAM / 2, ha, 0).translate((*ctr, h - ha))) rr = rr.cut( chamf_cyl(M3_CB_DIAM / 2, M3_CB_DEPTH, 0).translate( (*ctr, h - M3_CB_DEPTH) ) ) rx = recentre(self.hex_cut(depth=GR_HEX_D)) rh = rotate_x(rotate_z(rx, 90), 90) xo = cl + wh + GR_HEX_D / 2 yo = GR_HINGE_H1 + GR_HEX_H / 2 - 2 * GR_HINGE_SKEW zo = GR_HINGE_SEP / 2 + (self.hinge_width - 2) / 4 for pt in [(-GR_HEX_D / 2, yo, h / 2 - z) for z in (-zo, zo)]: rl = rl.union(rh.translate(pt)) rh = rotate_x(rotate_z(rx, -90), 90) for pt in [(xo, yo, h / 2 - z) for z in (-zo, zo)]: rr = rr.union(rh.translate(pt)) if as_closed: rl = rotate_z(rl.translate((-ctr[0], -ctr[1], 0)), 90) rr = rotate_z(rr.translate((-ctr[0], -ctr[1], 0)), -90) if section is not None: r = rr if section == "outer" else rl else: r = rl.union(rr) self._cq_obj = r self._obj_label = "hinge" return self._cq_obj def render(self): """Renders the rugged box body shell.""" self.check_dimensions() r = self.body_shell(as_lid=False) # hollow out rc = ( cq.Workplane("XY") .placeSketch(rounded_rect_sketch(self.length, self.width, GR_RAD)) .extrude(self.box_height - GR_RBOX_FLOOR) ) r = r.cut(rc.translate((0, 0, GR_RBOX_FLOOR))) # add registration features pts, rots = self.align_centres for pt, rot in zip(pts, rots): rc = chamf_rect( GR_REG_L, GR_REG_W, GR_REG_H, angle=rot, z_offset=self.box_height, tol=0.75, ) r = r.union(rc.translate(pt)) rq = quarter_circle(GR_REG_R0, GR_REG_R1, GR_REG_H, "bl") r = r.union(rq.translate(self.left_qtr_centre)) rq = quarter_circle(GR_REG_R0, GR_REG_R1, GR_REG_H, "br") r = r.union(rq.translate(self.right_qtr_centre)) # add handle mounts if self.front_handle and self.long_enough_for_handle: r = r.union( self.handle_mount(side="left").translate(self.left_handle_centre) ) r = r.union( self.handle_mount(side="right").translate(self.right_handle_centre) ) # add hinge mounts rc = self.hinge_mount() for pt in self.hinge_centres: r = r.cut(rc.translate(pt)) # add side handles if self.side_handles: w = min(GR_SIDE_HANDLE_W, self.box_width - 2 * GR_RBOX_CORNER_W) rh = self.side_handle(width=w) rl = rotate_z(rh, -90) rr = rotate_z(rh, 90) zo = self.box_height - self.lid_height r = r.union(rl.translate((-self.box_length / 2, 0, zo))) r = r.union(rr.translate((self.box_length / 2, 0, zo))) hw, l2 = w / 2, self.box_length / 2 vs = HasXCoordinateSelector([-l2, l2]) & HasYCoordinateSelector([-hw, hw]) r = r.edges("|Z").edges(vs).fillet(2.5) # add front label slot if self.front_label: r = r.union(self.label_slot().translate(self.label_centre)) # back feet if self.back_feet: rc = self.render_back_foot() for pt in self.hinge_centres: r = r.union(rc.translate((pt[0], pt[1], 0))) # add baseplate if self.inside_baseplate: rb = GridfinityBaseplate(self.length_u, self.width_u, ext_depth=1.6) r = r.union(rb.render().translate((0, 0, GR_RBOX_FLOOR))) r = r.edges(FlatEdgeSelector(GR_RBOX_FLOOR)).chamfer(0.8) else: rb = self.extrude_profile( rounded_rect_sketch(self.length, self.width, GR_RAD), [GR_RBOX_WALL] ) r = r.union(rb) self._cq_obj = r self._obj_label = "body" return self._cq_obj def render_lid(self): """Renders the rugged box lid.""" self.check_dimensions() r = self.body_shell(as_lid=True) if self.lid_baseplate: # hollow out top half rs = rounded_rect_sketch(self.length - GR_TOL, self.width - GR_TOL, GR_RAD) rc = self.extrude_profile(rs, [self.lid_height - 0.5, (1.0, -45)]) r = r.cut(rc) # add topside baseplate rb = GridfinityBaseplate( self.length_u, self.width_u, ext_depth=0.4, straight_bottom=True ) rb = rb.render() r = r.union(rb.translate((0, 0, 4.7 - 0.4))) elif self.lid_window: # hollow out completely rs = rounded_rect_sketch(self.length, self.width, GR_RAD) rc = self.extrude_profile(rs, [5]) r = r.cut(rc) # hollow out bottom rs = rounded_rect_sketch(self.length, self.width, GR_RAD) r = r.cut(cq.Workplane("XY").placeSketch(rs).extrude(4.6)) # add modified bottom extrusion with a looser fit if self.lid_baseplate: rs = self.extrude_profile( rounded_rect_sketch(35, 35, 0.8), [(2.82, -22.1), (5, -45)] ) rs = rs.faces(">Z").shell(-1.2) else: rs = self.extrude_profile( rounded_rect_sketch(35, 35, 0.8), [(2.82, -22.1), (4.1, -45), (9, -85), 2], ) ra = composite_from_pts(rs, self.grid_centres) ra = ra.translate((-self.half_l, -self.half_w, 0)) rs = rounded_rect_sketch(self.length, self.width, GR_RAD) ra = ra.intersect(cq.Workplane("XY").placeSketch(rs).extrude(GR_LID_WINDOW_H)) r = r.union(ra) r = r.edges( EdgeLengthSelector(33.4) & HasZCoordinateSelector(0, min_points=2) ).chamfer(0.75) # add optional stackable features if self.stackable: for k, v in self.qtr_centres(tol=0.125, at_height=self.lid_height).items(): rq = quarter_circle(GR_REG_R0, GR_REG_R1, GR_REG_H, k) r = r.union(rq.translate(v)) if self.lid_window: # hollow the grid apertures ht, tp = GR_LID_WINDOW_H, 34 he = GR_LID_WINDOW_H / math.cos(math.radians(tp)) rs = ( cq.Workplane("XY") .placeSketch(rounded_rect_sketch(30, 30, 1)) .extrude(he, taper=-tp) ) ra = composite_from_pts(rs, self.grid_centres) ra = ra.translate((-self.half_l, -self.half_w, 0)) r = r.cut(ra) # window slot ext = 20 l, w = self.lid_window_size(width_ext=-2 + ext, tol=0) rs = rounded_rect_sketch(l, w, 0.5) hlw = self.lid_height - GR_LID_WINDOW_H ht = hlw - self.window_th - 0.5 rc = ( cq.Workplane("XY") .rect(l, w) .workplane(offset=self.window_th) .rect(l, w) .workplane(offset=ht) .rect(l - 6, w - 6) .workplane(offset=self.lid_height) .rect(l - 6, w - 6) .loft(ruled=True) ) rc = rc.edges(VerticalEdgeSelector()).fillet(0.5) # rc = self.extrude_profile(rs, [self.window_th, (ht, 60), hlw], angle=True) r = r.cut(rc.translate((0, ext / 2, GR_LID_WINDOW_H))) rs = rounded_rect_sketch(self.length - 5, self.width - 5, GR_RAD) rc = self.extrude_profile(rs, [self.lid_height]) r = r.cut(rc.translate((0, 0, self.lid_height - ht))) # add hinge mounts rc = rotate_y(self.hinge_mount(), 180) for pt in self.hinge_centres: r = r.cut(rc.translate((pt[0], pt[1], 0))) # add window retaining screw holes if self.lid_window: rc = ( cq.Workplane("XY") .circle(M2_DIAM / 2) .extrude(5) .faces(">Z") .wires() .toPending() .extrude(0.8, taper=-45) .faces(" < \\_____|_| |_|\\__,_|_| |_|_| |_|_|\\__|\\__, | |____/ \\___/_/\\_\\ __/ | |___/ """ DESC = """ Make a customized/parameterized Gridfinity compatible box with many optional features. """ EPILOG = """ example usages: 2x3x5 box with magnet holes saved to STL file with default filename: $ gridfinitybox 2 3 5 -m -f stl 1x3x4 box with scoops, label strip, 3 internal partitions and specified name: $ gridfinitybox 1 3 4 -s -l -ld 3 -o MyBox.step Solid 3x3x3 box with 50% fill, unsupported magnet holes and no top lip: $ gridfinitybox 3 3 3 -d -r 0.5 -u -n Lite style box 3x2x3 with label strip, partitions, output to default SVG file: $ gridfinitybox 3 2 3 -e -l -ld 2 -f svg """ def main(): parser = argparse.ArgumentParser( description=DESC, epilog=EPILOG, prefix_chars="-+", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument( "length", metavar="length", type=str, help="Box length in U (1U = 42 mm)" ) parser.add_argument( "width", metavar="width", type=str, help="Box width in U (1U = 42 mm)" ) parser.add_argument( "height", metavar="height", type=str, help="Box height in U (1U = 7 mm)" ) parser.add_argument( "-m", "--magnetholes", action="store_true", default=False, help="Add bottom magnet/mounting holes", ) parser.add_argument( "-u", "--unsupported", action="store_true", default=False, help="Add bottom magnet holes with 3D printer friendly strips without support", ) parser.add_argument( "-n", "--nolip", action="store_true", default=False, help="Do not add mating lip to the top perimeter", ) parser.add_argument( "-s", "--scoops", action="store_true", default=False, help="Add finger scoops against each length-wise back wall", ) parser.add_argument( "-l", "--labels", action="store_true", default=False, help="Add label strips against each length-wise front wall", ) parser.add_argument( "-e", "--ecolite", action="store_true", default=False, help="Make economy / lite style box with no elevated floor", ) parser.add_argument( "-d", "--solid", action="store_true", default=False, help="Make solid (filled) box for customized storage", ) parser.add_argument( "-r", "--ratio", action="store", default=1.0, help="Solid box fill ratio 0.0 = minimum, 1.0 = full height", ) parser.add_argument( "-ld", "--lengthdiv", action="store", default=0, help="Split box length-wise with specified number of divider walls", ) parser.add_argument( "-wd", "--widthdiv", action="store", default=0, help="Split box width-wise with specified number of divider walls", ) parser.add_argument( "-wt", "--wall", action="store", default=1.0, help="Wall thickness (default=1 mm)", ) parser.add_argument( "-f", "--format", default="step", help="Output file format (STEP, STL, SVG) default=STEP", ) parser.add_argument( "-o", "--output", default=None, help="Output filename (inferred output file format with extension)", ) args = parser.parse_args() argsd = vars(args) solid_ratio = float(argsd["ratio"]) length_div = int(argsd["lengthdiv"]) width_div = int(argsd["widthdiv"]) wall = float(argsd["wall"]) box = GridfinityBox( length_u=int(argsd["length"]), width_u=int(argsd["width"]), height_u=int(argsd["height"]), holes=argsd["magnetholes"] or argsd["unsupported"], unsupported_holes=argsd["unsupported"], no_lip=argsd["nolip"], scoops=argsd["scoops"], labels=argsd["labels"], lite_style=argsd["ecolite"], solid=argsd["solid"], solid_ratio=solid_ratio, length_div=length_div, width_div=width_div, wall_th=wall, ) if argsd["ecolite"]: bs = "lite " elif argsd["solid"]: bs = "solid " else: bs = "" print(title) print("Version: %s" % (cqgridfinity.__version__)) print( "Gridfinity %sbox: %dU x %dU x %dU (%.1f mm x %.1f mm x %.1f mm), %.2f mm walls" % ( bs, box.length_u, box.width_u, box.height_u, box.length, box.width, box.height, box.wall_th, ) ) if argsd["solid"]: print( " solid height ratio: %.2f top height: %.2f mm / %.2f mm" % (solid_ratio, box.top_ref_height, box.max_height + GR_BOT_H) ) s = [] if argsd["unsupported"]: s.append("holes with no support") elif argsd["magnetholes"]: s.append("holes") if argsd["nolip"]: s.append("no lip") if argsd["scoops"]: s.append("scoops") if argsd["labels"]: s.append("label strips") if length_div: s.append("%d length-wise walls" % (length_div)) if width_div: s.append("%d width-wise walls" % (width_div)) if len(s): print(" with options: %s" % (", ".join(s))) if argsd["output"] is not None: fn = argsd["output"] else: fn = box.filename() s = ["\nBox generated and saved as"] if argsd["format"].lower() == "stl" or fn.lower().endswith(".stl"): if not fn.endswith(".stl"): fn = fn + ".stl" box.save_stl_file(filename=argsd["output"]) s.append("%s in STL format" % (fn)) elif argsd["format"].lower() == "svg" or fn.lower().endswith(".svg"): if not fn.endswith(".svg"): fn = fn + ".svg" box.save_svg_file(filename=argsd["output"]) s.append("%s in SVG format" % (fn)) else: if not fn.endswith(".step"): fn = fn + ".step" box.save_step_file(filename=argsd["output"]) s.append("%s in STEP format" % (fn)) print(" ".join(s)) if __name__ == "__main__": main() ================================================ FILE: cqgridfinity/scripts/ruggedbox.py ================================================ #! /usr/bin/env python3 """ command line script to make a rugged Gridfinity box """ import argparse import cqgridfinity from cqgridfinity import * title = """ ____ _ ____ | _ \ _ _ __ _ __ _ ___ __| | __ ) _____ __ | |_) | | | |/ _` |/ _` |/ _ \\/ _` | _ \\ / _ \\ \\/ / | _ <| |_| | (_| | (_| | __/ (_| | |_) | (_) > < |_| \\_\\\\__,_|\\__, |\\__, |\\___|\\__,_|____/ \\___/_/\\_\\ |___/ |___/ """ DESC = """ Make a customized/parameterized Gridfinity compatible rugged box enclosure. The minimum box size is 3U x 3U x 4U. """ EPILOG = """ example usage: 5 x 4 x 6 rugged box shell and lid saved to STL files: $ ruggedbox 5 4 6 --box --lid -f stl """ def save_asset(box, argsd, prefix=None): if argsd["output"] is not None: fn = argsd["output"] if box._obj_label is not None: for ext in (".stl", ".step", ".svg"): if fn.lower().endswith(ext): fn = fn.replace(ext, "_%s%s" % (box._obj_label, ext)) break else: fn = box.filename(prefix=prefix) s = ["Component generated and saved as"] if argsd["format"].lower() == "stl" or fn.lower().endswith(".stl"): if not fn.endswith(".stl"): fn = fn + ".stl" box.save_stl_file(filename=argsd["output"], prefix=prefix) s.append("%s in STL format" % (fn)) elif argsd["format"].lower() == "svg" or fn.lower().endswith(".svg"): if not fn.endswith(".svg"): fn = fn + ".svg" box.save_svg_file(filename=argsd["output"], prefix=prefix) s.append("%s in SVG format" % (fn)) else: if not fn.endswith(".step"): fn = fn + ".step" box.save_step_file(filename=argsd["output"], prefix=prefix) s.append("%s in STEP format" % (fn)) print(" ".join(s)) def main(): parser = argparse.ArgumentParser( description=DESC, epilog=EPILOG, prefix_chars="-+", formatter_class=argparse.RawTextHelpFormatter, ) parser.add_argument( "length", metavar="length", type=str, help="Box length in U (1U = 42 mm)" ) parser.add_argument( "width", metavar="width", type=str, help="Box width in U (1U = 42 mm)" ) parser.add_argument( "height", metavar="height", type=str, help="Box height in U (1U = 7 mm)" ) parser.add_argument( "+l", "--label", action="store_true", default=False, help="Add label window across the front wall", ) parser.add_argument( "-l", "--nolabel", action="store_true", default=False, help="Remove label window across the front wall", ) parser.add_argument( "+p", "--lidbaseplate", action="store_true", default=False, help="Add baseplate to top of the lid", ) parser.add_argument( "-p", "--nolidbaseplate", action="store_true", default=False, help="Smooth/plain lid", ) parser.add_argument( "+w", "--lidwindow", action="store_true", default=False, help="Add window slot to the lid", ) parser.add_argument( "-w", "--nolidwindow", action="store_true", default=False, help="Do not add window slot to the lid", ) parser.add_argument( "-wt", "--windowthickness", action="store", default=None, help="Thickness of lid windows (mm)", ) parser.add_argument( "+a", "--handle", action="store_true", default=False, help="Add front handle", ) parser.add_argument( "-a", "--nohandle", action="store_true", default=False, help="No front handle", ) parser.add_argument( "+c", "--clasps", action="store_true", default=False, help="Add clasps to the left and right side walls", ) parser.add_argument( "-c", "--noclasps", action="store_true", default=False, help="No clasps on the left and right side walls", ) parser.add_argument( "+s", "--stackable", action="store_true", default=False, help="Add stackable mating features to top and bottom", ) parser.add_argument( "-s", "--notstackable", action="store_true", default=False, help="Non-stackable box", ) parser.add_argument( "+v", "--veegroove", action="store_true", default=False, help="Add v-cut grooves to side walls", ) parser.add_argument( "-v", "--noveegroove", action="store_true", default=False, help="No v-cut grooves (plain) side walls", ) parser.add_argument( "+e", "--sidehandle", action="store_true", default=False, help="Add handles to side walls", ) parser.add_argument( "-e", "--nosidehandle", action="store_true", default=False, help="No handles on side walls", ) parser.add_argument( "+b", "--backfeet", action="store_true", default=False, help="Add standing feet to back wall", ) parser.add_argument( "-b", "--nobackfeet", action="store_true", default=False, help="No standing feet added to back wall", ) parser.add_argument( "-r", "--normalstyle", action="store_true", default=False, help="Make normal style box", ) parser.add_argument( "+r", "--ribstyle", action="store_true", default=False, help="Make rib style box with exposed vertical ribs", ) parser.add_argument( "-f", "--format", default="step", help="Output file format (STEP, STL, SVG) default=STEP", ) parser.add_argument( "-o", "--output", default=None, help="Output filename (inferred output file format with extension)", ) parser.add_argument( "-gb", "--box", action="store_true", default=False, help="Generate box", ) parser.add_argument( "-gl", "--lid", action="store_true", default=False, help="Generate lid", ) parser.add_argument( "-ga", "--acc", action="store_true", default=False, help="Generate accessory components", ) parser.add_argument( "-gh", "--hinge", action="store_true", default=False, help="Generate hinge element", ) parser.add_argument( "-ge", "--genlabel", action="store_true", default=False, help="Generate label panel insert", ) parser.add_argument( "-gn", "--genhandle", action="store_true", default=False, help="Generate front handle", ) parser.add_argument( "-gt", "--genlatch", action="store_true", default=False, help="Generate latch component", ) parser.add_argument( "-gw", "--genwindow", action="store_true", default=False, help="Generate lid window component", ) args = parser.parse_args() argsd = vars(args) box = GridfinityRuggedBox( length_u=int(argsd["length"]), width_u=int(argsd["width"]), height_u=int(argsd["height"]), ) if argsd["lidbaseplate"]: box.lid_baseplate = True if argsd["nolidbaseplate"]: box.lid_baseplate = False if argsd["lidwindow"]: box.lid_window = True if argsd["nolidwindow"]: box.lid_window = False if argsd["handle"]: box.front_handle = True if argsd["nohandle"]: box.front_handle = False if argsd["label"]: box.front_label = True if argsd["nolabel"]: box.front_label = False if argsd["clasps"]: box.side_clasps = True if argsd["noclasps"]: box.side_clasps = False if argsd["stackable"]: box.stackable = True if argsd["notstackable"]: box.stackable = False if argsd["veegroove"]: box.wall_vgrooves = True if argsd["noveegroove"]: box.wall_vgrooves = False if argsd["sidehandle"]: box.side_handles = True if argsd["nosidehandle"]: box.side_handles = False if argsd["backfeet"]: box.back_feet = True if argsd["nobackfeet"]: box.back_feet = False if argsd["ribstyle"]: box.rib_style = True if argsd["normalstyle"]: box.rib_style = False if argsd["windowthickness"] is not None: box.window_th = float(argsd["windowthickness"]) print(title) print("Version: %s" % (cqgridfinity.__version__)) print( "Gridfinity rugged box: %dU x %dU x %dU" % ( box.length_u, box.width_u, box.height_u, ) ) print( " Exterior dim: %.1f mm x %.1f mm x %.1f mm" % ( box.box_length + 2 * (GR_RBOX_CWALL - GR_RBOX_WALL), box.box_width + 2 * (GR_RBOX_CWALL - GR_RBOX_WALL), box.box_height + box.lid_height, ) ) print( " Interior dim: %.1f mm x %.1f mm x %.1f mm" % ( box.length, box.width, box.height, ) ) print(" Internal volume: %.3f L" % (box.length * box.width * box.height / 1e6)) if box.lid_window: print( " Lid window dimensions: %.2f x %.2f mm, %.2f mm thickness" % (*box.lid_window_size(), box.window_th) ) s = [] opts = [ "wall_vgrooves", "front_handle", "stackable", "side_clasps", "lid_baseplate", "inside_baseplate", "side_handles", "front_label", "back_feet", "rib_style", ] for opt in opts: opt_name = opt.replace("_", " ").title() val = "Y" if box.__dict__[opt] else "N" print(" %-19s: %s" % (opt_name, val)) print(" %-19s: %s" % ("Lid Window", "Y" if box.lid_window else "N")) if argsd["output"] is not None: fn = argsd["output"] else: fn = box.filename() g = False if argsd["box"]: print("Rendering box...") box.render() save_asset(box, argsd) g = True if argsd["lid"]: print("Rendering lid...") box.render_lid() save_asset(box, argsd) g = True if argsd["acc"]: print("Rendering accessory components...") r = box.render_accessories() save_asset(box, argsd) g = True if argsd["hinge"]: print("Rendering hinge components...") r = box.render_hinge() save_asset(box, argsd) g = True if argsd["genlabel"]: print("Rendering label panel...") r = box.render_label() save_asset(box, argsd) g = True if argsd["genhandle"]: print("Rendering front handle...") r = box.render_handle() save_asset(box, argsd) g = True if argsd["genlatch"]: print("Rendering latch component...") r = box.render_latch() save_asset(box, argsd) g = True if argsd["genwindow"]: print( "Rendering lid window (%.2f x %.2f mm, %.2f mm thickness)..." % (*box.lid_window_size(), box.window_th) ) r = box.render_lid_window() save_asset(box, argsd) g = True if not g: print("Rendering full assembly...") a = box.render_assembly() if argsd["output"] is not None: fn = argsd["output"] else: fn = box.filename() if not fn.endswith(".step"): fn = fn + ".step" a.save(fn) if __name__ == "__main__": main() ================================================ FILE: cqgridfinity/shims/README.md ================================================ # /pub/storage/workspace/gridfinity Created by Zach Freedman as a versatile system of modular organization and storage modules. This package defines the basic building blocks of the Gridfinity system. Make use of the parameters to customize the parts to your needs. ## Parts ### baseplate
Parameters:
  • length_u: 2
  • width_u: 2
  • ext_depth: 0.0
  • straight_bottom: False
  • corner_screws: False
  • corner_tab_size: 21.0
  • csk_hole: 5.0
  • csk_diam: 10.0
  • csk_angle: 82.0
### box
Parameters:
  • length_u: 2
  • width_u: 2
  • height_u: 2
  • length_div: 0.0
  • width_div: 0.0
  • scoops: False
  • labels: False
  • solid: False
  • holes: False
  • no_lip: False
  • solid_ratio: 1.0
  • lite_style: False
  • unsupported_holes: False
  • label_width: 12.0
  • label_height: 10.0
  • label_lip_height: 0.8
  • scoop_rad: 12.0
  • fillet_interior: True
  • wall_th: 1.0
### drawerspacer
Parameters:
  • length_u: 2
  • width_u: 2
  • length_th: 10.0
  • width_th: 10.0
  • thickness: 5.0
  • chamf_rad: 1.0
  • show_arrows: True
  • arrow_h: 0.8
  • length_fill: 0.0
  • width_fill: 0.0
  • align_features: True
  • align_l: 16.0
  • align_tol: 0.15
  • align_min: 8.0
  • min_margin: 4.0
  • tolerance: 0.5
### ruggedbox
Parameters:
  • length_u: 4
  • width_u: 4
  • height_u: 4
  • lid_height: 10.0
  • wall_vgrooves: True
  • front_handle: True
  • stackable: True
  • side_clasps: True
  • lid_baseplate: True
  • inside_baseplate: True
  • side_handles: True
  • front_label: True
  • label_length: 0.0
  • label_height: 0.0
  • label_th: 0.5
  • back_feet: True
  • hinge_width: 48.0
  • hinge_bolted: False


*Generated by [PartCAD](https://partcad.org/)* ================================================ FILE: cqgridfinity/shims/cqgi_gf_baseplate.py ================================================ import sys sys.path.append(".") # Relative to `partcad.yaml` from cqgridfinity.gf_baseplate import GridfinityBaseplate length_u = 2 width_u = 2 ext_depth = 0.0 straight_bottom = False corner_screws = False corner_tab_size = 21 csk_hole = 5.0 csk_diam = 10.0 csk_angle = 82 result = GridfinityBaseplate( length_u=int(length_u), width_u=int(width_u), ext_depth=ext_depth, straight_bottom=straight_bottom, corner_screws=corner_screws, corner_tab_size=corner_tab_size, csk_hole=csk_hole, csk_diam=csk_diam, csk_angle=csk_angle, ).render().val() show_object(result) ================================================ FILE: cqgridfinity/shims/cqgi_gf_box.py ================================================ import sys sys.path.append(".") # Relative to `partcad.yaml` from cqgridfinity.gf_box import GridfinityBox length_u = 2 width_u = 2 height_u = 2 length_div = 0.0 width_div = 0.0 scoops = False labels = False solid = False holes = False no_lip = False solid_ratio = 1.0 lite_style = False unsupported_holes = False label_width = 12.0 # width of the label strip label_height = 10.0 # thickness of label overhang label_lip_height = 0.8 # thickness of label vertical lip scoop_rad = 12.0 # radius of optional interior scoops fillet_interior = True wall_th = 1.0 result = GridfinityBox( length_u=int(length_u), width_u=int(width_u), height_u=int(height_u), length_div=length_div, width_div=width_div, scoops=scoops, labels=labels, solid=solid, holes=holes, no_lip=no_lip, solid_ratio=solid_ratio, lite_style=lite_style, unsupported_holes=unsupported_holes, label_width=label_width, label_height=label_height, label_lip_height=label_lip_height, scoop_rad=scoop_rad, fillet_interior=fillet_interior, wall_th=wall_th, ).render().val() show_object(result) ================================================ FILE: cqgridfinity/shims/cqgi_gf_drawerspacer.py ================================================ import sys sys.path.append(".") # Relative to `partcad.yaml` from cqgridfinity.gf_drawer import GridfinityDrawerSpacer length_u = 2 width_u = 2 length_th = 10.0 width_th = 10.0 thickness = 5.0 chamf_rad = 1.0 show_arrows = True arrow_h = 0.8 length_fill = 0.0 width_fill = 0.0 align_features = True align_l = 16.0 align_tol = 0.15 align_min = 8.0 min_margin = 4.0 tolerance = 0.5 result = GridfinityDrawerSpacer( length_u=int(length_u), width_u=int(width_u), length_th=length_th, width_th=width_th, thickness=thickness, chamf_rad=chamf_rad, show_arrows=show_arrows, arrow_h=arrow_h, length_fill=length_fill, width_fill=width_fill, align_features=align_features, align_l=align_l, align_tol=align_tol, align_min=align_min, min_margin=min_margin, tolerance=tolerance, ).render().val() show_object(result) ================================================ FILE: cqgridfinity/shims/cqgi_gf_ruggedbox.py ================================================ import sys sys.path.append(".") # Relative to `partcad.yaml` from cqgridfinity.gf_ruggedbox import GridfinityRuggedBox length_u = 4 width_u = 4 height_u = 4 lid_height = 10.0 wall_vgrooves = True front_handle = True stackable = True side_clasps = True lid_baseplate = True inside_baseplate = True side_handles = True front_label = True # TODO(clairbee): uncomment the below when annotations are supported by CQGI # label_length: float = None # label_height: float = None label_length = 0.0 label_height = 0.0 if label_length == 0.0: label_length = None if label_height == 0.0: label_height = None label_th = 0.8 back_feet = True hinge_width = 48.0 hinge_bolted = False rib_style = False result = GridfinityRuggedBox( length_u=int(length_u), width_u=int(width_u), height_u=int(height_u), lid_height=lid_height, wall_vgrooves=wall_vgrooves, front_handle=front_handle, stackable=stackable, side_clasps=side_clasps, lid_baseplate=lid_baseplate, inside_baseplate=inside_baseplate, side_handles=side_handles, front_label=front_label, label_length=label_length, label_height=label_height, label_th=label_th, back_feet=back_feet, hinge_width=hinge_width, hinge_bolted=hinge_bolted, rib_style=rib_style, ).render().val() show_object(result) ================================================ FILE: examples/demo1.assy ================================================ # This is a demo of multiple gridfinity parts put together using PartCAD. # Use `pc show -a examples/demo1` to view it in OCP CAD Viewer or # use `pc render -t png -a examples/demo1` to render a PNG image of this assembly. links: - part: baseplate name: baseplate - part: box name: box location: [[100, 0, 0], [0, 0, 1], 0] - part: drawerspacer name: drawerspacerbox location: [[-200, 0, 0], [0, 0, 1], 0] ================================================ FILE: partcad.yaml ================================================ partcad: ">=0.7.16" name: /pub/storage/workspace/gridfinity desc: Created by Zach Freedman as a versatile system of modular organization and storage modules. cover: part: ruggedbox docs: intro: | This package defines the basic building blocks of the Gridfinity system. Make use of the parameters to customize the parts to your needs. footer: | ## Implementation notes This packages has a folder with PartCAD shims. This folder contains wrappers for cqgridfinity main Python files to make them compatible with CadQuery's CQGI interface that is used by PartCAD. This is a non-intrusive alternative to refactoring cqgridfinity main Python files. If cqgridfinity adopts CQGI, then these shims can be dropped. parts: baseplate: type: cadquery path: cqgridfinity/shims/cqgi_gf_baseplate.py parameters: length_u: type: int default: 2 width_u: type: int default: 2 ext_depth: type: float default: 0.0 straight_bottom: type: bool default: False corner_screws: type: bool default: False corner_tab_size: type: float default: 21.0 csk_hole: type: float default: 5.0 csk_diam: type: float default: 10.0 csk_angle: type: float default: 82.0 ruggedbox: type: cadquery path: cqgridfinity/shims/cqgi_gf_ruggedbox.py parameters: length_u: type: int default: 4 width_u: type: int default: 4 height_u: type: int default: 4 lid_height: type: float default: 10.0 wall_vgrooves: type: bool default: True front_handle: type: bool default: True stackable: type: bool default: True side_clasps: type: bool default: True lid_baseplate: type: bool default: True inside_baseplate: type: bool default: True side_handles: type: bool default: True front_label: type: bool default: True label_length: type: float default: 0.0 label_height: type: float default: 0.0 label_th: type: float default: 0.8 back_feet: type: bool default: True hinge_width: type: float default: 48.0 hinge_bolted: type: bool default: False rib_style: type: bool default: False box: type: cadquery path: cqgridfinity/shims/cqgi_gf_box.py parameters: length_u: type: int default: 2 width_u: type: int default: 2 height_u: type: int default: 2 length_div: type: float default: 0.0 width_div: type: float default: 0.0 scoops: type: bool default: False labels: type: bool default: False solid: type: bool default: False holes: type: bool default: False no_lip: type: bool default: False solid_ratio: type: float default: 1.0 lite_style: type: bool default: False unsupported_holes: type: bool default: False label_width: type: float default: 12.0 # width of the label strip label_height: type: float default: 10.0 # thickness of label overhang label_lip_height: type: float default: 0.8 # thickness of label vertical lip scoop_rad: type: float default: 12.0 # radius of optional interior scoops fillet_interior: type: bool default: True wall_th: type: float default: 1.0 drawerspacer: type: cadquery path: cqgridfinity/shims/cqgi_gf_drawerspacer.py parameters: length_u: type: int default: 2 width_u: type: int default: 2 length_th: type: float default: 10.0 width_th: type: float default: 10.0 thickness: type: float default: 5.0 chamf_rad: type: float default: 1.0 show_arrows: type: bool default: True arrow_h: type: float default: 0.8 length_fill: type: float default: 0.0 width_fill: type: float default: 0.0 align_features: type: bool default: True align_l: type: float default: 16.0 align_tol: type: float default: 0.15 align_min: type: float default: 8.0 min_margin: type: float default: 4.0 tolerance: type: float default: 0.5 assemblies: examples/demo1: type: assy render: svg: prefix: cqgridfinity/shims exclude: - assemblies readme: path: cqgridfinity/shims/README.md exclude: - assemblies ================================================ FILE: requirements.in ================================================ cadquery cqkit>=0.5.6 ================================================ FILE: requirements.txt ================================================ # # This file is autogenerated by pip-compile with Python 3.12 # by the following command: # # pip-compile requirements.in # cadquery==2.4.0 # via -r requirements.in cadquery-ocp==7.7.2 # via cadquery casadi==3.6.7 # via cadquery cqkit==0.5.8 # via -r requirements.in ezdxf==1.3.4 # via cadquery fonttools==4.55.2 # via ezdxf multimethod==1.9.1 # via cadquery nlopt==2.9.0 # via cadquery nptyping==2.0.1 # via cadquery numpy==2.1.3 # via # casadi # ezdxf # nlopt # nptyping path==17.0.0 # via cadquery pyparsing==3.2.0 # via ezdxf typing-extensions==4.12.2 # via ezdxf typish==1.9.3 # via cadquery ================================================ FILE: setup.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path from pathlib import Path import sys import setuptools PACKAGE_NAME = "cqgridfinity" required = ["cadquery", "cqkit>=0.5.6"] dependency_links = [] def read_package_variable(key, filename="__init__.py"): """Read the value of a variable from the package without importing.""" module_path = os.path.join(PACKAGE_NAME, filename) with open(module_path) as module: for line in module: parts = line.strip().split(" ", 2) if parts[:-1] == [key, "="]: return parts[-1].strip("'") sys.exit("'{0}' not found in '{1}'".format(key, module_path)) this_directory = Path(__file__).parent long_description = (this_directory / "README.md").read_text() setuptools.setup( name=read_package_variable("__project__"), version=read_package_variable("__version__"), description="A python library to make Gridfinity compatible objects with CadQuery.", url="https://github.com/michaelgale/cq-gridfinity", author="Michael Gale", author_email="michael@fxbricks.com", python_requires=">=3.9", packages=setuptools.find_packages(), long_description=long_description, long_description_content_type="text/markdown", license="MIT", classifiers=[ "Development Status :: 4 - Beta", "Natural Language :: English", "Operating System :: OS Independent", "Programming Language :: Python :: 3.9", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", ], install_requires=required, dependency_links=dependency_links, entry_points={ "console_scripts": [ "gridfinitybox=cqgridfinity.scripts.gridfinitybox:main", "gridfinitybase=cqgridfinity.scripts.gridfinitybase:main", "ruggedbox=cqgridfinity.scripts.ruggedbox:main", ], }, ) ================================================ FILE: tests/common_test.py ================================================ import os EXPORT_STEP_FILE_PATH = "./tests/testfiles" env = dict(os.environ) SKIP_TEST_BOX = "SKIP_TEST_BOX" in env SKIP_TEST_RBOX = "SKIP_TEST_RBOX" in env SKIP_TEST_SPACER = "SKIP_TEST_SPACER" in env SKIP_TEST_BASEPLATE = "SKIP_TEST_BASEPLATE" in env def INCHES(x): return x * 25.4 def _faces_match(obj, face, n): nf = len(obj.faces(face).vals()) return nf == n def _edges_match(obj, face, n): nf = len(obj.faces(face).edges().vals()) return abs(nf - n) < 3 def _almost_same(x, y, tol=1e-3): if isinstance(x, (list, tuple)): return all((abs(xe - ye) < tol for xe, ye in zip(x, y))) return abs(x - y) < tol def _export_files(spec="all"): if "EXPORT_STEP_FILES" in env: exp_var = env["EXPORT_STEP_FILES"].lower() if exp_var == "all": return True elif exp_var == spec.lower(): return True return False return False ================================================ FILE: tests/test_baseplate.py ================================================ # Gridfinity tests import pytest # my modules from cqgridfinity import * from cqkit import FlatEdgeSelector from cqkit.cq_helpers import size_3d from common_test import ( EXPORT_STEP_FILE_PATH, _almost_same, _faces_match, _export_files, SKIP_TEST_BASEPLATE, ) @pytest.mark.skipif( SKIP_TEST_BASEPLATE, reason="Skipped intentionally by test scope environment variable", ) def test_make_baseplate(): bp = GridfinityBaseplate(4, 3) r = bp.render() if _export_files("baseplate"): bp.save_step_file(path=EXPORT_STEP_FILE_PATH) assert bp.filename() == "gf_baseplate_4x3" assert _almost_same(size_3d(r), (168, 126, 4.75)) assert _faces_match(r, ">Z", 16) assert _faces_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "Z", 1) assert _faces_match(r, "Z", 16) assert _edges_match(r, "