├── MANIFEST.in ├── barbari ├── __init__.py ├── configs │ ├── default_isolation_routing.yaml │ ├── default_alignment_holes.yaml │ ├── default_edge_cuts.yaml │ ├── drilled_04_10.yaml │ ├── copper_wire_vias.yaml │ ├── slots_10.yaml │ ├── milled_10.yaml │ ├── milled_11.yaml │ ├── simple.yaml │ ├── drilled_pth_04_11.yaml │ ├── rivets.yaml │ ├── coddingtonbear.yaml │ └── example.yaml ├── exceptions.py ├── constants.py ├── commands │ ├── display_config.py │ ├── generate_config.py │ ├── build.py │ ├── setup_flatcam.py │ ├── list_configs.py │ ├── __init__.py │ └── build_script.py ├── main.py ├── gerbers.py ├── config.py └── flatcam.py ├── requirements.txt ├── .gitignore ├── .vscode └── launch.json ├── .github └── workflows │ └── lint.yml ├── setup.cfg ├── setup.py └── readme.md /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include barbari/configs/*.yaml 2 | -------------------------------------------------------------------------------- /barbari/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "3.0.3" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pcb-tools>=0.1.6,<1 2 | appdirs>=1.4.3,<2 3 | rich>=12,<13 4 | pyyaml>=5.4.1,<6 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | include/* 3 | src/* 4 | share/* 5 | lib/* 6 | .vscode/* 7 | __pycache__ 8 | *.egg-info 9 | *.pyc 10 | -------------------------------------------------------------------------------- /barbari/configs/default_isolation_routing.yaml: -------------------------------------------------------------------------------- 1 | isolation_routing: 2 | tool_size: 0.18 3 | passes: 5 4 | pass_overlap: 0.5 5 | cut_z: -0.2 6 | travel_z: 2 7 | feed_rate: 200 8 | spindle_speed: 12000 9 | -------------------------------------------------------------------------------- /barbari/configs/default_alignment_holes.yaml: -------------------------------------------------------------------------------- 1 | alignment_holes: 2 | hole_size: 3.4 3 | hole_offset: 1 4 | tool_size: 1.5 5 | cut_z: -10 6 | travel_z: 2 7 | feed_rate: 100 8 | spindle_speed: 12000 9 | multi_depth: true 10 | depth_per_pass: 0.2 11 | -------------------------------------------------------------------------------- /barbari/configs/default_edge_cuts.yaml: -------------------------------------------------------------------------------- 1 | edge_cuts: 2 | tool_size: 1.5 3 | margin: 0 4 | gap_size: 1 5 | gaps: lr 6 | cut_z: -2.5 7 | travel_z: 2 8 | feed_rate: 100 9 | spindle_speed: 12000 10 | multi_depth: true 11 | depth_per_pass: 0.2 12 | -------------------------------------------------------------------------------- /barbari/configs/drilled_04_10.yaml: -------------------------------------------------------------------------------- 1 | drill: 2 | drilled: 3 | min_size: 0.4 4 | max_size: 1.0 5 | specs: 6 | - type: cnc_drill 7 | params: 8 | tool_size: 1.0 9 | drill_z: -2.5 10 | travel_z: 2 11 | feed_rate: 50 12 | spindle_speed: 12000 13 | -------------------------------------------------------------------------------- /barbari/configs/copper_wire_vias.yaml: -------------------------------------------------------------------------------- 1 | drill: 2 | via: 3 | max_size: 0.4 4 | sizes: 5 | - 0.4 6 | specs: 7 | - type: cnc_drill 8 | params: 9 | tool_size: 0.4 10 | drill_z: -2.5 11 | travel_z: 2 12 | feed_rate: 50 13 | spindle_speed: 12000 14 | -------------------------------------------------------------------------------- /barbari/exceptions.py: -------------------------------------------------------------------------------- 1 | class BarbariError(Exception): 2 | pass 3 | 4 | 5 | class BarbariUserError(BarbariError): 6 | pass 7 | 8 | 9 | class ConfigNotFound(BarbariUserError): 10 | pass 11 | 12 | 13 | class InvalidConfiguration(BarbariUserError): 14 | pass 15 | 16 | 17 | class BarbariFlatcamError(Exception): 18 | pass 19 | -------------------------------------------------------------------------------- /barbari/configs/slots_10.yaml: -------------------------------------------------------------------------------- 1 | slot: 2 | large: 3 | min_size: 1.0 4 | specs: 5 | - type: mill_slots 6 | params: 7 | tool_size: 1 8 | cut_z: -2.5 9 | travel_z: 2 10 | feed_rate: 100 11 | spindle_speed: 12000 12 | multi_depth: true 13 | depth_per_pass: 0.2 14 | -------------------------------------------------------------------------------- /barbari/configs/milled_10.yaml: -------------------------------------------------------------------------------- 1 | drill: 2 | milled: 3 | min_size: 1.0 4 | specs: 5 | - type: mill_holes 6 | params: 7 | tool_size: 1 8 | cut_z: -2.5 9 | travel_z: 2 10 | feed_rate: 100 11 | spindle_speed: 12000 12 | multi_depth: true 13 | depth_per_pass: 0.2 14 | -------------------------------------------------------------------------------- /barbari/configs/milled_11.yaml: -------------------------------------------------------------------------------- 1 | drill: 2 | milled: 3 | min_size: 1.1 4 | specs: 5 | - type: mill_holes 6 | params: 7 | tool_size: 1 8 | cut_z: -2.5 9 | travel_z: 2 10 | feed_rate: 100 11 | spindle_speed: 12000 12 | multi_depth: true 13 | depth_per_pass: 0.2 14 | -------------------------------------------------------------------------------- /barbari/configs/simple.yaml: -------------------------------------------------------------------------------- 1 | description: | 2 | Simple configuration using copper wire vias, 3 | 10mm deep alignment holes, and drilled 1mm 4 | holes, and milled holes larger than 1mm, and 5 | and edge cuts. 6 | include: 7 | - ./default_isolation_routing.yaml 8 | - ./default_alignment_holes.yaml 9 | - ./copper_wire_vias.yaml 10 | - ./drilled_04_10.yaml 11 | - ./milled_10.yaml 12 | - ./slots_10.yaml 13 | - ./default_edge_cuts.yaml 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | {"name":"Python: Remote Attach","type":"python","request":"attach","connect":{"host":"localhost","port":5678},"pathMappings":[{"localRoot":"${workspaceFolder}","remoteRoot":"."}]}, 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /barbari/configs/drilled_pth_04_11.yaml: -------------------------------------------------------------------------------- 1 | drill: 2 | pth: 3 | min_size: 0.4 4 | max_size: 1.1 5 | specs: 6 | - type: cnc_drill 7 | params: 8 | tool_size: 1.0 9 | drill_z: -2.5 10 | travel_z: 2 11 | feed_rate: 50 12 | spindle_speed: 12000 13 | - type: cnc_drill 14 | params: 15 | tool_size: 1.5 16 | drill_z: -2.5 17 | travel_z: 2 18 | feed_rate: 50 19 | spindle_speed: 12000 20 | -------------------------------------------------------------------------------- /barbari/configs/rivets.yaml: -------------------------------------------------------------------------------- 1 | description: | 2 | A configuration using copper wire vias, 3 | 10mm deep alignment holes, [voltera 1mm rivets](https://www.voltera.io/store/consumables/rivets-1-0mm) 4 | for holes near 1mm, and milled holes 5 | larger than 1.1mm. Note that holes being drilled 6 | for voltera rivets will be drilled at 1.5mm to allow 7 | clearance for the rivets. 8 | include: 9 | - ./default_isolation_routing.yaml 10 | - ./default_alignment_holes.yaml 11 | - ./copper_wire_vias.yaml 12 | - ./drilled_pth_04_11.yaml 13 | - ./milled_11.yaml 14 | - ./slots_10.yaml 15 | - ./default_edge_cuts.yaml 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: push 3 | jobs: 4 | run-linters: 5 | name: Run linters 6 | runs-on: ubuntu-latest 7 | 8 | steps: 9 | - name: Check out Git repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Set up Python 13 | uses: actions/setup-python@v1 14 | with: 15 | python-version: '3.10' 16 | 17 | - name: Install Python dependencies 18 | run: pip install black flake8 19 | 20 | - name: Run linters 21 | uses: samuelmeuli/lint-action@v1.5.3 22 | with: 23 | github_token: ${{ secrets.github_token }} 24 | # Enable linters 25 | black: true 26 | flake8: true 27 | -------------------------------------------------------------------------------- /barbari/constants.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class LayerType(enum.Enum): 5 | B_CU = "b_cu" 6 | F_CU = "f_cu" 7 | EDGE_CUTS = "edge_cuts" 8 | DRILL = "drill" 9 | ALIGNMENT = "alignment" 10 | 11 | 12 | class FlatcamLayer(enum.Enum): 13 | B_CU = "b_cu" 14 | B_CU_PATH = "b_cu_path" 15 | B_CU_CNC = "b_cu_cnc" 16 | F_CU = "f_cu" 17 | F_CU_PATH = "f_cu_path" 18 | F_CU_CNC = "f_cu_cnc" 19 | ALIGNMENT = "edge_cuts_aligndrill" 20 | ALIGNMENT_PATH = "edge_cuts_aligndrill_path" 21 | ALIGNMENT_CNC = "edge_cuts_aligndrill_cnc" 22 | EDGE_CUTS = "edge_cuts" 23 | EDGE_CUTS_PATH = "edge_cuts_cutout" 24 | EDGE_CUTS_CNC = "edge_cuts_cnc" 25 | DRILL = "drill" 26 | -------------------------------------------------------------------------------- /barbari/commands/display_config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from rich.syntax import Syntax 4 | import yaml 5 | 6 | from .. import config 7 | from . import BaseCommand 8 | 9 | 10 | class Command(BaseCommand): 11 | FORMAT_HUMAN = "human" 12 | FORMAT_YAML = "yaml" 13 | 14 | @classmethod 15 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 16 | parser.add_argument( 17 | "config", 18 | nargs="+", 19 | help="Configuration file to display; later configs override earlier configs -- you can use this to layer your configuration.", 20 | ) 21 | return super().add_arguments(parser) 22 | 23 | def handle(self) -> None: 24 | conf = config.get_merged_config(self.options.config) 25 | 26 | self.console.print(Syntax(yaml.safe_dump(conf._data), "yaml")) 27 | -------------------------------------------------------------------------------- /barbari/commands/generate_config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import shutil 4 | 5 | from .. import config 6 | from . import BaseCommand 7 | 8 | 9 | class Command(BaseCommand): 10 | DEFAULT_CONFIG = "example.yaml" 11 | 12 | @classmethod 13 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 14 | parser.add_argument( 15 | "name", help="Name of the configuration you would like to generate." 16 | ) 17 | return super().add_arguments(parser) 18 | 19 | def handle(self) -> None: 20 | final_path = f"{self.options.name}.yaml" 21 | 22 | os.makedirs(config.get_user_config_dir(), exist_ok=True) 23 | 24 | final_path = os.path.join( 25 | config.get_user_config_dir(), 26 | final_path, 27 | ) 28 | 29 | shutil.copyfile( 30 | os.path.join( 31 | config.get_default_config_dir(), 32 | self.DEFAULT_CONFIG, 33 | ), 34 | final_path, 35 | ) 36 | 37 | self.console.print( 38 | f"Configuration '{self.options.name}' written to '{final_path}'." 39 | ) 40 | -------------------------------------------------------------------------------- /barbari/commands/build.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import subprocess 3 | 4 | from ..exceptions import BarbariFlatcamError 5 | from .build_script import Command as BuildScriptCommand 6 | 7 | 8 | class Command(BuildScriptCommand): 9 | @classmethod 10 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 11 | parser.add_argument( 12 | "--flatcam", 13 | help="Path to flatcam executable (FlatCAM.py)", 14 | ) 15 | parser.add_argument( 16 | "--python-bin", 17 | help=( 18 | "Path to the python binary to use when running " 19 | "FlatCAM.py; set this to the correct python " 20 | "binary for a virtualenvironment if you are " 21 | "using one." 22 | ), 23 | ) 24 | return super().add_arguments(parser) 25 | 26 | def handle(self) -> None: 27 | output_file = self.build_script() 28 | 29 | self.console.print(f"Wrote g-code generation script to {output_file}.") 30 | 31 | proc = subprocess.Popen( 32 | [ 33 | self.options.python_bin or self.config.python_bin or "python", 34 | self.options.flatcam or self.config.flatcam_path or "./FlatCam.py", 35 | f"--shellfile={output_file}", 36 | ], 37 | ) 38 | result = proc.wait() 39 | 40 | if result != 0: 41 | raise BarbariFlatcamError( 42 | "Failed to execute flatcam script; see output above." 43 | ) 44 | 45 | self.console.print("Flatcam executed successfully.") 46 | -------------------------------------------------------------------------------- /barbari/configs/coddingtonbear.yaml: -------------------------------------------------------------------------------- 1 | description: | 2 | The configuration used by @coddingtonbear 3 | for his day-to-day repeatable milling setup. 4 | Unlike the standard 'rivets' setup, this configuration 5 | requires that footprints be explicitly modified to 6 | indicate when voltera rivets should be used. 7 | drill: 8 | voltera: 9 | sizes: 10 | - 1.5 11 | specs: 12 | - type: cnc_drill 13 | params: 14 | tool_size: 1.0 15 | drill_z: -2.5 16 | travel_z: 2 17 | feed_rate: 50 18 | spindle_speed: 12000 19 | - type: cnc_drill 20 | params: 21 | tool_size: 1.5 22 | drill_z: -2.5 23 | travel_z: 2 24 | feed_rate: 50 25 | spindle_speed: 12000 26 | minimill: 27 | min_size: 0.6 28 | max_size: 1.0 29 | specs: 30 | - type: mill_slots 31 | params: 32 | tool_size: 0.6 33 | cut_z: -2.5 34 | travel_z: 2 35 | feed_rate: 25 36 | spindle_speed: 12000 37 | multi_depth: true 38 | depth_per_pass: 0.2 39 | slot: 40 | small: 41 | min_size: 0.6 42 | max_size: 1.0 43 | specs: 44 | - type: mill_slots 45 | params: 46 | tool_size: 0.6 47 | cut_z: -2.5 48 | travel_z: 2 49 | feed_rate: 25 50 | spindle_speed: 12000 51 | multi_depth: true 52 | depth_per_pass: 0.2 53 | include: 54 | - ./default_isolation_routing.yaml 55 | - ./default_alignment_holes.yaml 56 | - ./copper_wire_vias.yaml 57 | - ./drilled_04_10.yaml 58 | - ./milled_10.yaml 59 | - ./slots_10.yaml 60 | - ./default_edge_cuts.yaml 61 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # https://github.com/ambv/black#line-length 3 | max-line-length = 88 4 | ignore = 5 | # E203: whitespace before ':' (defer to black) 6 | E203, 7 | # E231: missing whitespace after ',' (defer to black) 8 | E231, 9 | # E501: line length (defer to black) 10 | E501, 11 | # W503: break before binary operators (defer to black) 12 | W503, 13 | # A003: [builtins] allow class attributes to be named after builtins (e.g., `id`) 14 | A003, 15 | exclude = 16 | migrations, 17 | 18 | [pep8] 19 | max-line-length = 88 20 | ignore = 21 | # E701: multiple statements on one line (flags py3 inline type hints) 22 | E701, 23 | 24 | [pydocstyle] 25 | # Harvey's initial attempt at pydocstyle rules. Please suggest improvements! 26 | # The `(?!\d{4}_)` pattern is a hacky way to exclude migrations, since pydocstyle doesn't 27 | # have an `exclude` option. https://github.com/PyCQA/pydocstyle/issues/175 28 | match = (?!test_)(?!\d{4}_).*\.py 29 | 30 | # See http://www.pydocstyle.org/en/5.0.1/error_codes.html 31 | ignore = 32 | # D100: docstring in public module (we don't have a practice around this) 33 | D100, 34 | # D104: docstring in public package (we don't have a practice around this) 35 | D104, 36 | # D105: docstring in magic method 37 | D105, 38 | # D105: docstring in __init__ method 39 | D107, 40 | # D203: Blank line required before class docstring 41 | D203, 42 | # D213: Multi-line docstring summary should start at the second line (need to choose D212 or D213; see https://stackoverflow.com/a/45990465) 43 | D213, 44 | # D302: Use u”“” for Unicode docstrings 45 | D302, 46 | 47 | [tool:isort] 48 | force_single_line = True 49 | line_length = 88 50 | known_first_party = jira_select 51 | default_section = THIRDPARTY 52 | skip = .tox,.eggs,build,dist 53 | -------------------------------------------------------------------------------- /barbari/configs/example.yaml: -------------------------------------------------------------------------------- 1 | description: Example barbari configuration 2 | alignment_holes: 3 | hole_size: 3.4 4 | hole_offset: 1 5 | tool_size: 1.5 6 | cut_z: -10 7 | travel_z: 2 8 | feed_rate: 100 9 | spindle_speed: 12000 10 | multi_depth: true 11 | depth_per_pass: 0.2 12 | isolation_routing: 13 | tool_size: 0.18 14 | width: 1 15 | pass_overlap: 1 16 | cut_z: -0.2 17 | travel_z: 2 18 | feed_rate: 200 19 | spindle_speed: 12000 20 | multi_depth: true 21 | depth_per_pass: 0.1 22 | drill: 23 | via: 24 | max_size: 0.4 25 | specs: 26 | - type: cnc_drill 27 | params: 28 | tool_size: 0.4 29 | drill_z: -2.5 30 | travel_z: 2 31 | feed_rate: 50 32 | spindle_speed: 12000 33 | drilled: 34 | min_size: 0.4 35 | max_size: 1.0 36 | specs: 37 | - type: cnc_drill 38 | params: 39 | tool_size: 1.0 40 | drill_z: -2.5 41 | travel_z: 2 42 | feed_rate: 50 43 | spindle_speed: 12000 44 | milled: 45 | min_size: 1.0 46 | specs: 47 | - type: mill_holes 48 | params: 49 | tool_size: 1 50 | cut_z: -2.5 51 | travel_z: 2 52 | feed_rate: 100 53 | spindle_speed: 12000 54 | multi_depth: true 55 | depth_per_pass: 0.2 56 | slot: 57 | large: 58 | min_size: 1.0 59 | specs: 60 | - type: mill_slots 61 | params: 62 | tool_size: 1 63 | cut_z: -2.5 64 | travel_z: 2 65 | feed_rate: 100 66 | spindle_speed: 12000 67 | multi_depth: true 68 | depth_per_pass: 0.2 69 | edge_cuts: 70 | tool_size: 1.5 71 | margin: 0 72 | gap_size: 1 73 | gaps: lr 74 | cut_z: -2.5 75 | travel_z: 2 76 | feed_rate: 100 77 | spindle_speed: 12000 78 | multi_depth: true 79 | depth_per_pass: 0.2 80 | -------------------------------------------------------------------------------- /barbari/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | 5 | from rich.console import Console 6 | from rich.logging import RichHandler 7 | from rich.traceback import install as enable_rich_traceback 8 | 9 | from . import exceptions 10 | from .commands import get_installed_commands 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def main(*args): 17 | enable_rich_traceback() 18 | 19 | commands = get_installed_commands() 20 | 21 | parser = argparse.ArgumentParser() 22 | parser.add_argument("--debug", default=False, action="store_true") 23 | parser.add_argument("--verbose", default=False, action="store_true") 24 | subparsers = parser.add_subparsers(dest="command") 25 | subparsers.required = True 26 | 27 | for cmd_name, cmd_class in commands.items(): 28 | parser_kwargs = {} 29 | 30 | cmd_help = cmd_class.get_help() 31 | if cmd_help: 32 | parser_kwargs["help"] = cmd_help 33 | 34 | subparser = subparsers.add_parser(cmd_name, **parser_kwargs) 35 | cmd_class._add_arguments(subparser) 36 | 37 | args = parser.parse_args() 38 | 39 | logging.basicConfig( 40 | format="%(message)s", 41 | datefmt="[%X]", 42 | handlers=[RichHandler()], 43 | level=logging.DEBUG if args.verbose else logging.INFO, 44 | ) 45 | 46 | if args.debug: 47 | import debugpy 48 | 49 | debugpy.listen(5678) 50 | debugpy.wait_for_client() 51 | 52 | console = Console() 53 | 54 | try: 55 | commands[args.command](args).handle() 56 | except exceptions.BarbariError as e: 57 | console.print(f"[red]{e}[/red]") 58 | except exceptions.BarbariUserError as e: 59 | console.print(f"[yellow]{e}[/yellow]") 60 | except Exception: 61 | console.print_exception() 62 | 63 | 64 | if __name__ == "__main__": 65 | main(*sys.argv[1:]) 66 | -------------------------------------------------------------------------------- /barbari/commands/setup_flatcam.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from barbari.exceptions import BarbariFlatcamError 3 | import subprocess 4 | 5 | from .. import config 6 | from . import BaseCommand 7 | 8 | 9 | class Command(BaseCommand): 10 | @classmethod 11 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 12 | parser.add_argument( 13 | "--flatcam", 14 | # default="./FlatCAM.py", 15 | required=True, 16 | help="Path to flatcam executable (FlatCAM.py)", 17 | ) 18 | parser.add_argument( 19 | "--python-bin", 20 | default="python", 21 | help=( 22 | "Path to the python binary to use when running " 23 | "FlatCAM.py; set this to the correct python " 24 | "binary for a virtualenvironment if you are " 25 | "using one." 26 | ), 27 | ) 28 | return super().add_arguments(parser) 29 | 30 | def handle(self) -> None: 31 | proc = subprocess.Popen( 32 | [ 33 | self.options.python_bin, 34 | self.options.flatcam, 35 | "--help", 36 | ], 37 | stdout=subprocess.PIPE, 38 | stderr=subprocess.PIPE, 39 | ) 40 | stdout, stderr = proc.communicate() 41 | if "--shellfile" not in stdout.decode("utf-8"): 42 | raise BarbariFlatcamError( 43 | "Could not start FlatCam using the provided parameters: " 44 | f"stdout: {stdout.decode('utf-8', 'replace')}, stderr: {stderr.decode('utf-8', 'replace')}" 45 | ) 46 | 47 | self.config.flatcam_path = self.options.flatcam 48 | self.config.python_bin = self.options.python_bin 49 | 50 | config.save_environment_config(self.config) 51 | self.console.print("[green]Flatcam found. [b]Configuration saved[/b][/green]") 52 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | import uuid 4 | 5 | from barbari import __version__ as version_string 6 | 7 | 8 | requirements_path = os.path.join( 9 | os.path.dirname(__file__), 10 | "requirements.txt", 11 | ) 12 | try: 13 | from pip.req import parse_requirements 14 | 15 | requirements = [ 16 | str(req.req) 17 | for req in parse_requirements(requirements_path, session=uuid.uuid1()) 18 | ] 19 | except ImportError: 20 | requirements = [] 21 | with open(requirements_path, "r") as in_: 22 | requirements = [ 23 | req 24 | for req in in_.readlines() 25 | if not req.startswith("-") and not req.startswith("#") 26 | ] 27 | 28 | 29 | setup( 30 | name="barbari", 31 | version=version_string, 32 | url="https://github.com/coddingtonbear/barbari", 33 | description=( 34 | "Automates Flatcam generation of G-code for my (and maybe your) PCB milling process." 35 | ), 36 | author="Adam Coddington", 37 | author_email="me@adamcoddington.net", 38 | classifiers=[ 39 | "License :: OSI Approved :: MIT License", 40 | "Operating System :: OS Independent", 41 | "Programming Language :: Python :: 3", 42 | ], 43 | install_requires=requirements, 44 | packages=find_packages(), 45 | include_package_data=True, 46 | entry_points={ 47 | "console_scripts": ["barbari = barbari.main:main"], 48 | "barbari.commands": [ 49 | "generate-config = barbari.commands.generate_config:Command", 50 | "build = barbari.commands.build:Command", 51 | "build-script = barbari.commands.build_script:Command", 52 | "list-configs = barbari.commands.list_configs:Command", 53 | "display-config = barbari.commands.display_config:Command", 54 | "setup-flatcam = barbari.commands.setup_flatcam:Command", 55 | ], 56 | }, 57 | ) 58 | -------------------------------------------------------------------------------- /barbari/gerbers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from typing import Dict 5 | 6 | import gerber 7 | 8 | from .constants import LayerType 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class UnknownLayerType(ValueError): 15 | pass 16 | 17 | 18 | class GerberProject(object): 19 | LAYER_NAME_PATTERNS: Dict[LayerType, re.Pattern] = { 20 | LayerType.B_CU: re.compile(".*\-B(?:[._])Cu\..*"), 21 | LayerType.F_CU: re.compile(".*\-F(?:[._])Cu\..*"), 22 | LayerType.EDGE_CUTS: re.compile(".*\-Edge(?:[._])Cuts"), 23 | LayerType.ALIGNMENT: re.compile(".*-Alignment\..*"), 24 | LayerType.DRILL: re.compile(".*\.drl$"), 25 | } 26 | 27 | def __init__(self, path): 28 | self._path = path 29 | self._layers = {} 30 | 31 | super().__init__() 32 | 33 | @property 34 | def path(self) -> str: 35 | return self._path 36 | 37 | def detect_layer_type(self, filename: str, layer): 38 | for layer_type, pattern in self.LAYER_NAME_PATTERNS.items(): 39 | if pattern.match(filename): 40 | return layer_type 41 | 42 | raise UnknownLayerType("Unable to guess layer position for {}".format(filename)) 43 | 44 | def get_layers(self): 45 | if self._layers: 46 | return self._layers 47 | 48 | for filename in os.listdir(self._path): 49 | full_path = os.path.join( 50 | self._path, 51 | filename, 52 | ) 53 | if not os.path.isfile(full_path): 54 | continue 55 | 56 | try: 57 | layer = gerber.read(full_path) 58 | layer_type = self.detect_layer_type(filename, layer) 59 | self._layers[layer_type] = layer 60 | logger.debug("Loaded %s", full_path) 61 | except UnknownLayerType: 62 | logger.error("Could not identify layer type for %s.", full_path) 63 | except gerber.common.ParseError: 64 | logger.debug("Unable to parse %s; probably not a gerber.", full_path) 65 | 66 | return self._layers 67 | -------------------------------------------------------------------------------- /barbari/commands/list_configs.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from typing import Dict 3 | 4 | from rich.markdown import Markdown 5 | 6 | from .. import config 7 | from . import BaseCommand 8 | 9 | 10 | class Command(BaseCommand): 11 | @classmethod 12 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 13 | parser.add_argument( 14 | "--all", 15 | action="store_true", 16 | help=( 17 | "By default, only configurations having a written " 18 | "description are shown. If you would like to see " 19 | "all configurations, including those that are intended " 20 | "to be used as includes, use this flag." 21 | ), 22 | ) 23 | return super().add_arguments(parser) 24 | 25 | def handle(self) -> None: 26 | to_show: Dict[str, config.Config] = {} 27 | all_configs = config.get_available_configs() 28 | 29 | for config_name in all_configs: 30 | conf = config.get_config_by_name(config_name) 31 | 32 | if self.options.all or conf.description: 33 | to_show[config_name] = conf 34 | 35 | if len(to_show) != len(all_configs): 36 | self.console.print( 37 | f"Showing {len(to_show)} of {len(all_configs)} configs; " 38 | "use --all to see more.", 39 | style="red", 40 | ) 41 | 42 | for config_name, conf in to_show.items(): 43 | formatted = Markdown(conf.description or "") 44 | 45 | self.console.print(f"[blue][b]{config_name}[/b][/blue]") 46 | if formatted: 47 | self.console.print(formatted, style="italic") 48 | if conf.alignment_holes: 49 | self.console.print("- Alignment Holes") 50 | if conf.isolation_routing: 51 | self.console.print("- Isolation Routing") 52 | if conf.edge_cuts: 53 | self.console.print("- Edge Cuts") 54 | if conf.drill: 55 | self.console.print("- Drill Profiles") 56 | for k, v in conf.drill.items(): 57 | self.console.print( 58 | f" - {k}: {v.min_size or '0'}-{v.max_size or 'Infinity'}" 59 | ) 60 | -------------------------------------------------------------------------------- /barbari/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABCMeta, abstractmethod 4 | import argparse 5 | import logging 6 | import pkg_resources 7 | from typing import Dict, Type 8 | 9 | from rich.console import Console 10 | 11 | from ..config import EnvironmentConfig, get_environment_config 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def get_installed_commands(): 18 | possible_commands: Dict[str, Type[BaseCommand]] = {} 19 | for entry_point in pkg_resources.iter_entry_points(group="barbari.commands"): 20 | try: 21 | loaded_class = entry_point.load() 22 | except ImportError: 23 | logger.warning( 24 | "Attempted to load entrypoint %s, but " "an ImportError occurred.", 25 | entry_point, 26 | ) 27 | continue 28 | if not issubclass(loaded_class, BaseCommand): 29 | logger.warning( 30 | "Loaded entrypoint %s, but loaded class is " 31 | "not a subclass of `barbari.commands.BaseCommand`.", 32 | entry_point, 33 | ) 34 | continue 35 | possible_commands[entry_point.name] = loaded_class 36 | 37 | return possible_commands 38 | 39 | 40 | class BaseCommand(metaclass=ABCMeta): 41 | _options: argparse.Namespace 42 | _console: Console 43 | _config: EnvironmentConfig 44 | 45 | def __init__(self, options: argparse.Namespace): 46 | self._options: argparse.Namespace = options 47 | self._console = Console() 48 | self._config = get_environment_config() 49 | super().__init__() 50 | 51 | @property 52 | def config(self) -> EnvironmentConfig: 53 | return self._config 54 | 55 | @property 56 | def options(self) -> argparse.Namespace: 57 | return self._options 58 | 59 | @property 60 | def console(self) -> Console: 61 | return self._console 62 | 63 | @classmethod 64 | def get_help(cls) -> str: 65 | return "" 66 | 67 | @classmethod 68 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 69 | pass 70 | 71 | @classmethod 72 | def _add_arguments(cls, parser: argparse.ArgumentParser) -> None: 73 | cls.add_arguments(parser) 74 | 75 | @abstractmethod 76 | def handle(self) -> None: 77 | ... 78 | -------------------------------------------------------------------------------- /barbari/commands/build_script.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | from typing import List 5 | 6 | from rich.prompt import Confirm 7 | 8 | from .. import config, gerbers, flatcam 9 | from . import BaseCommand 10 | 11 | 12 | class Command(BaseCommand): 13 | OUTPUT_PATTERN = re.compile(r"^\d+\..*\.gcode$") 14 | 15 | @classmethod 16 | def add_arguments(cls, parser: argparse.ArgumentParser) -> None: 17 | parser.add_argument( 18 | "directory", help="Path to a directory holding your gerber/drl exports." 19 | ) 20 | parser.add_argument( 21 | "config", 22 | nargs="+", 23 | help="Configuration file to use; later configs override earlier configs -- you can use this to layer your configuration.", 24 | ) 25 | return super().add_arguments(parser) 26 | 27 | def get_existing_output(self) -> List[str]: 28 | existing_files = [] 29 | 30 | for filename in os.listdir( 31 | os.path.abspath(os.path.expanduser(self.options.directory)) 32 | ): 33 | if self.OUTPUT_PATTERN.match(filename): 34 | existing_files.append(filename) 35 | 36 | return existing_files 37 | 38 | def build_script(self) -> str: 39 | project = gerbers.GerberProject( 40 | os.path.abspath(os.path.expanduser(self.options.directory)) 41 | ) 42 | generator = flatcam.FlatcamProjectGenerator( 43 | project, config.get_merged_config(self.options.config) 44 | ) 45 | 46 | existing_files = self.get_existing_output() 47 | if existing_files: 48 | self.console.print("The following existing flatcam output was found: ") 49 | for filename in sorted(existing_files): 50 | self.console.print(f"- {filename}") 51 | delete_existing = Confirm.ask("Would you like to delete these?") 52 | if delete_existing: 53 | for filename in existing_files: 54 | os.unlink( 55 | os.path.join( 56 | os.path.abspath(os.path.expanduser(self.options.directory)), 57 | filename, 58 | ) 59 | ) 60 | 61 | output_file = os.path.join( 62 | self.options.directory, 63 | "generate_gcode.FlatScript", 64 | ) 65 | processes = generator.get_cnc_processes() 66 | with open(output_file, "w") as outf: 67 | for process in processes: 68 | outf.write(str(process)) 69 | outf.write("\n") 70 | 71 | return output_file 72 | 73 | def handle(self) -> None: 74 | output_file = self.build_script() 75 | 76 | self.console.print(f"Wrote g-code generation script to {output_file}.") 77 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![header-image](http://coddingtonbear-public.s3.amazonaws.com/github/barbari/header.jpg) 2 | 3 | # Barbari: An automation script for generating milling gcode via Flatcam 4 | 5 | I use a cheap 1610 CNC machine, KiCad, and Flatcam for milling PCBs, but remembering exactly which settings to use for each page in Flatcam for generating my milling gcode is a pain, and I occasionally mistype important values (they're almost all important!). This flatcam automation script is intended to eliminate that problem by taking my memory and observation skills out of the equation. 6 | 7 | ## Requirements 8 | 9 | - Flatcam: `Beta` branch (tested with 8.994) 10 | 11 | ## Quickstart 12 | 13 | Export your gerber & drill files: 14 | 15 | 1. Place the auxiliary axis for your board in the lower-left corner of 16 | your board. ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_aux_axis.png) 17 | 2. Open the plot settings dialog from "File", "Plot". ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_plot.png) 18 | 3. Set plot settings as shown below, then click "Plot"; ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_plot_settings.png) specifically make sure that you're: 19 | - Plotting the F.Cu, B.Cu, and Edge.Cuts layers 20 | - "Use auxiliary axis as origin" is checked. 21 | 4. Click the "Generate Drill Files..." button. ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_drill_button.png) 22 | 5. Set the drill settings as shown below, and click "Generate drill file"; ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_drill_settings.png) specifically make sure that you've set the following settings correctly: 23 | - Exporting in the "Excellon" file format. 24 | - "PTH and NPTH in a single file" is checked. 25 | - Drill Origin is set to "Auxiliary axis" 26 | - Drill Units is set to "Millimeters" 27 | 6. Make note of the path to which these files were written for use below as `/path/to/gerber/exports`. ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_path_gerber.png) ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_gerber_path_drill.png) 28 | 6. Close the displayed dialogs. 29 | 30 | Generate your flatcam script: 31 | 32 | ``` 33 | barbari build-script /path/to/gerber/exports simple 34 | ``` 35 | 36 | "simple" in the above string is the name of the milling configuration to use for the generated flatcam script. Alternative options exist -- see "Configuration" below for details. 37 | 38 | Run your script in flatcam: 39 | 40 | 1. From Flatcam, open the "File", "Scripting", "Run Script". ![](https://coddingtonbear-public.s3-us-west-2.amazonaws.com/github/barbari/instructions_flatcam_menu.png) 41 | 2. Select the script generated in `/path/to/gerber/exports`. 42 | 43 | Run your generated gcode in whatever tool you use for sending gcode to your mill. Note that the files will be stored in `/path/to/gerber/exports` and are expected to be run in the order indicated by their file names. 44 | 45 | ## How does milling a PCB work? 46 | 47 | Roughly, the process is handled via the following steps: 48 | 49 | 1. Milling Alignment holes (`alignment_holes` in the configuration). These are used to allow you to perfectly flip your PCB for milling on both sides. 50 | 2. Isolation routing (`isolation_routing` in the configuration). This will route your PCB's traces on both sides of the board. First, the back of your PCB (`B.Cu`) is routed, then the front (`F.Cu`). You'll want to flip over your board, using the drilled alignment holes in between these two steps. 51 | 3. Drilling (`drill` in the configuration). 52 | 4. Milling slots (`slot` in the configuration). 53 | 5. Edge cuts (`edge_cuts` in the configuration). This is for cutting your PCB out of the larger piece of copper-clad board. 54 | 55 | ## Configuration 56 | 57 | Barbari comes packaged with a couple milling profiles for two fairly conservative sets of milling operations; more than likely, the "simple" profile will be enough for you, but if it's not, you can either use one of the other packaged configuration settings (see `list-configs` and `display-config` to see their details) or create your own by exporting an example configuration and modifying it (see `generate-config`). 58 | 59 | Note that Barbari configuration files can be layered atop one another by providing more than a single configuration file. Properties defined in later configuration files take precedence over properties in earlier ones, and drilling profiles are merged together. 60 | 61 | ### Sections 62 | 63 | Your configuration file is divided into multiple sections for the various steps of the milling process. Those sections are used for generating instructions for Flatcam. 64 | 65 | Note that all properties use metric units -- usually `millimeters` unless specifically noted. 66 | 67 | #### `description` 68 | 69 | This is a short string describing this milling profile, and is used only for display in Barbari. 70 | 71 | #### `alignment_holes` 72 | 73 | This is used to allow you to perfectly flip your PCB for milling on both sides. 74 | 75 | Example: 76 | 77 | ```yaml 78 | alignment_holes: 79 | hole_size: 3.4 80 | hole_offset: 1 81 | tool_size: 1.5 82 | cut_z: -10 83 | travel_z: 2 84 | feed_rate: 100 85 | spindle_speed: 12000 86 | multi_depth: true 87 | depth_per_pass: 0.2 88 | ``` 89 | 90 | #### `isolation_routing` 91 | 92 | This is used to route the traces on your board. 93 | 94 | Example: 95 | 96 | ```yaml 97 | isolation_routing: 98 | tool_size: 0.18 99 | width: 1 100 | pass_overlap: 1 101 | cut_z: -0.2 102 | travel_z: 2 103 | feed_rate: 200 104 | spindle_speed: 12000 105 | multi_depth: true 106 | depth_per_pass: 0.1 107 | ``` 108 | 109 | #### `drill` 110 | 111 | You probably don't have as many bits on hand as a PCB board house will; so these sections are here to allow you to group multiple drill sizes into sets of processes. For example, if you had only three bits -- a 0.4mm drill for vias, a 1.0mm drill for most through-holes, and a 1.0mm mill for everything bigger than that, you could have a section like this: 112 | 113 | ```yaml 114 | drill: 115 | via: 116 | max_size: 0.4 117 | specs: 118 | - type: cnc_drill 119 | params: 120 | tool_size: 0.4 121 | drill_z: -2.5 122 | travel_z: 2 123 | feed_rate: 50 124 | spindle_speed: 12000 125 | drilled: 126 | min_size: 0.4 127 | max_size: 1.0 128 | specs: 129 | - type: cnc_drill 130 | params: 131 | tool_size: 1.0 132 | drill_z: -2.5 133 | travel_z: 2 134 | feed_rate: 50 135 | spindle_speed: 12000 136 | milled: 137 | min_size: 1.0 138 | specs: 139 | - type: mill_holes 140 | params: 141 | tool_size: 1 142 | cut_z: -2.5 143 | travel_z: 2 144 | feed_rate: 100 145 | spindle_speed: 12000 146 | multi_depth: true 147 | depth_per_pass: 0.2 148 | ``` 149 | 150 | You'll see from the above that any holes up to 0.4mm in diameter will be drilled using the `via` processes (called `specs` here) -- drilling with a 0.4mm drill, any drills from 0.4mm to 1.0mm in size will be drilled using a 1.0mm drill bit, and anything bigger than 1.0mm will be milled using a 1.0mm end mill. You might 151 | notice that some drill bit sizes match multiple specs -- that's fine -- barbari 152 | will choose the best process for each particular tool algorithmically. 153 | 154 | In some situations -- mostly around drilling large holes -- you might need a particular range of drill sizes to be drilled more than once. For example, the included drilling profile for using [voltera 1mm rivets](https://www.voltera.io/store/consumables/rivets-1-0mm) for plated through-holes looks like this: 155 | 156 | ```yaml 157 | pth: 158 | min_size: 0.4 159 | max_size: 1.1 160 | specs: 161 | - type: cnc_drill 162 | params: 163 | tool_size: 0.7 164 | drill_z: -2.5 165 | travel_z: 2 166 | feed_rate: 50 167 | spindle_speed: 12000 168 | - type: cnc_drill 169 | params: 170 | tool_size: 1.5 171 | drill_z: -2.5 172 | travel_z: 2 173 | feed_rate: 50 174 | spindle_speed: 12000 175 | ``` 176 | 177 | The above configuration will drill any holes from 0.4mm to 1.1mm in diameter twice -- first with a 0.7mm drill, and then afterward with a 1.5mm drill. 178 | 179 | #### `slot` 180 | 181 | The slot section defines job parameters for milling slots in your PCB (i.e. non-round holes). It follows exactly the same pattern used for `drill` above. 182 | 183 | #### `edge_cuts` 184 | 185 | You probably won't be using an entire sheet of copper-clad board for your board. This section defines how to cut your newly-milled PCB out of the copper-clad. 186 | 187 | 188 | #### `include` 189 | 190 | This section is special, and is used for including _other_ configuration files into the configuration file. It is a list of strings that can be either: 191 | 192 | - A relative (to the configuration file) path to a different configuration file to include. This must end in `.yaml` or `.yml`. This is the method to use if you *would not* like user-level configurations to override the pre-packaged ones. 193 | - The name of a configuration to use. See `list-configs --all` for a list of configuration files. This is the method to use if you *would* like user-level configurations to override the pre-packaged ones. 194 | 195 | For example, this is the "rivets" configuration: 196 | 197 | ```yaml 198 | description: | 199 | The standard configuration @coddingtonbear 200 | uses when milling his PCBs. This uses 201 | copper wire vias, 10mm deep alignment holes, [voltera 1mm rivets](https://www.voltera.io/store/consumables/rivets-1-0mm) 202 | for holes near 1mm, and milled holes 203 | larger than 1.1mm. 204 | include: 205 | - ./default_isolation_routing.yaml 206 | - ./default_alignment_holes.yaml 207 | - ./copper_wire_vias.yaml 208 | - ./drilled_pth_04_11.yaml 209 | - ./milled_11.yaml 210 | - ./default_edge_cuts.yaml 211 | ``` 212 | -------------------------------------------------------------------------------- /barbari/config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from dataclasses import dataclass, asdict 5 | import logging 6 | import os 7 | from typing import cast, Dict, Iterable, List, Optional, Type, Union, Tuple 8 | 9 | import appdirs 10 | import yaml 11 | 12 | from . import exceptions 13 | 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class JobSpec(object): 19 | def __init__(self, data, name=None): 20 | self._data = data 21 | self._name = name 22 | 23 | super().__init__() 24 | 25 | @property 26 | def name(self) -> str: 27 | return self._name 28 | 29 | @property 30 | def tool_size(self) -> float: 31 | return self._data["tool_size"] 32 | 33 | @property 34 | def cut_z(self) -> float: 35 | return self._data["cut_z"] 36 | 37 | @property 38 | def travel_z(self) -> float: 39 | return self._data["travel_z"] 40 | 41 | @property 42 | def feed_rate(self) -> float: 43 | return self._data["feed_rate"] 44 | 45 | @property 46 | def spindle_speed(self) -> int: 47 | return self._data["spindle_speed"] 48 | 49 | @property 50 | def multi_depth(self) -> bool: 51 | return self._data.get("multi_depth", False) 52 | 53 | @property 54 | def depth_per_pass(self) -> float: 55 | return self._data.get("depth_per_pass") 56 | 57 | def __repr__(self): 58 | return f"<{self.__class__.__name__}: {self.name or self._data}>" 59 | 60 | 61 | class MillHolesJobSpec(JobSpec): 62 | pass 63 | 64 | 65 | class MillSlotsJobSpec(MillHolesJobSpec): 66 | pass 67 | 68 | 69 | class IsolationRoutingJobSpec(JobSpec): 70 | @property 71 | def passes(self) -> float: 72 | return self._data["passes"] 73 | 74 | @property 75 | def pass_overlap(self) -> float: 76 | return self._data.get("pass_overlap", 1.0) 77 | 78 | 79 | class BoardCutoutJobSpec(JobSpec): 80 | @property 81 | def margin(self) -> float: 82 | return self._data["margin"] 83 | 84 | @property 85 | def gap_size(self) -> float: 86 | return self._data["gap_size"] 87 | 88 | @property 89 | def gaps(self) -> str: 90 | return self._data["gaps"] 91 | 92 | 93 | class DrillHolesJobSpec(JobSpec): 94 | @property 95 | def drill_z(self) -> float: 96 | return self._data.get("drill_z") 97 | 98 | 99 | class ToolProfileSpec(JobSpec): 100 | @property 101 | def min_size(self) -> float: 102 | return self._data.get("min_size", 0) 103 | 104 | @property 105 | def max_size(self) -> float: 106 | return self._data.get("max_size", 999) 107 | 108 | @property 109 | def range(self) -> Tuple[float, float]: 110 | return self.min_size, self.max_size 111 | 112 | @property 113 | def range_center(self) -> float: 114 | return self.min_size + ((self.max_size - self.min_size) / 2) 115 | 116 | def allowed_for_tool_size(self, diameter: float) -> bool: 117 | range_allowed = "min_size" in self._data or "max_size" in self._data 118 | 119 | if ( 120 | range_allowed and (self.min_size <= diameter <= self.max_size) 121 | ) or diameter in self.sizes: 122 | return True 123 | 124 | return False 125 | 126 | def is_better_match_than( 127 | self, diameter: float, other: Optional[ToolProfileSpec] 128 | ) -> bool: 129 | if not other: 130 | return True 131 | 132 | # If one spec specifically mentions a particular drill 133 | # size, it wins. 134 | if diameter in self.sizes and diameter not in other.sizes: 135 | return True 136 | elif diameter in other.sizes and diameter not in self.sizes: 137 | return False 138 | 139 | # If one spec is using only drilling profiles, and matches 140 | # our hole size exactly, that one wins 141 | if ( 142 | all(isinstance(spec, DrillHolesJobSpec) for spec in self.specs) 143 | and max(spec.tool_size <= diameter for spec in self.specs) == diameter 144 | ): 145 | return True 146 | elif ( 147 | all(isinstance(spec, DrillHolesJobSpec) for spec in other.specs) 148 | and max(spec.tool_size <= diameter for spec in other.specs) == diameter 149 | ): 150 | return False 151 | 152 | # If one spec is all milling profiles with a tool size below 153 | # our diameter, that side wins 154 | if all(isinstance(spec, MillHolesJobSpec) for spec in self.specs) and all( 155 | spec.tool_size <= diameter for spec in self.specs 156 | ): 157 | return True 158 | elif all(isinstance(spec, MillHolesJobSpec) for spec in other.specs) and all( 159 | spec.tool_size <= diameter for spec in other.specs 160 | ): 161 | return False 162 | 163 | if abs(diameter - self.range_center) < abs(diameter - other.range_center): 164 | return True 165 | elif abs(diameter - other.range_center) < abs(diameter - self.range_center): 166 | return False 167 | 168 | return False 169 | 170 | @property 171 | def sizes(self) -> List[float]: 172 | return self._data.get("sizes", []) 173 | 174 | @property 175 | def specs(self) -> Iterable[Union[MillHolesJobSpec, DrillHolesJobSpec]]: 176 | specs = [] 177 | 178 | for spec_data in self._data["specs"]: 179 | data = spec_data["params"] 180 | spec_type = spec_data["type"] 181 | spec_class: Union[Type[MillHolesJobSpec], Type[DrillHolesJobSpec]] 182 | 183 | if spec_type == "cnc_drill": 184 | spec_class = DrillHolesJobSpec 185 | elif spec_type == "mill_holes": 186 | spec_class = MillHolesJobSpec 187 | elif spec_type == "mill_slots": 188 | spec_class = MillSlotsJobSpec 189 | else: 190 | raise exceptions.InvalidConfiguration( 191 | "Unexpected spec type: %s" % spec_type 192 | ) 193 | 194 | specs.append(spec_class(data)) 195 | 196 | return specs 197 | 198 | 199 | class DrillProfileSpec(ToolProfileSpec): 200 | @property 201 | def specs(self) -> Iterable[Union[MillHolesJobSpec, DrillHolesJobSpec]]: 202 | specs = super().specs 203 | 204 | for spec in specs: 205 | if not isinstance(spec, (DrillHolesJobSpec, MillHolesJobSpec)): 206 | raise exceptions.InvalidConfiguration( 207 | "Drills support only 'cnc_drill' and 'mill_holes' specifications." 208 | ) 209 | 210 | return specs 211 | 212 | 213 | class SlotProfileSpec(ToolProfileSpec): 214 | @property 215 | def specs(self) -> Iterable[MillSlotsJobSpec]: 216 | specs = super().specs 217 | 218 | for spec in specs: 219 | if not isinstance(spec, MillSlotsJobSpec): 220 | raise exceptions.InvalidConfiguration( 221 | "Slots support only 'mill_slots' specifications." 222 | ) 223 | 224 | yield from cast(Iterable[MillSlotsJobSpec], specs) 225 | 226 | 227 | class AlignmentHolesJobSpec(MillHolesJobSpec): 228 | @property 229 | def mirror_axis(self) -> str: 230 | return self._data.get("mirror_axis", "X") 231 | 232 | @property 233 | def hole_size(self) -> float: 234 | return self._data["hole_size"] 235 | 236 | @property 237 | def hole_offset(self) -> float: 238 | return self._data["hole_offset"] 239 | 240 | 241 | class Config(object): 242 | def __init__(self, data): 243 | self._data = data 244 | 245 | @classmethod 246 | def from_file(self, path) -> Config: 247 | configs: List[Config] = [] 248 | 249 | with open(path, "r") as inf: 250 | loaded = yaml.safe_load(inf) 251 | 252 | includes = loaded.pop("include", []) 253 | 254 | configs.append(Config(loaded)) 255 | 256 | config_dir = os.path.dirname(path) 257 | for include in includes: 258 | if os.path.splitext(include)[1] in (".yaml", ".yml"): 259 | include_path = os.path.join(config_dir, include) 260 | 261 | configs.append(Config.from_file(include_path)) 262 | else: 263 | configs.append(get_config_by_name(include)) 264 | 265 | merged = sum(configs, Config({})) 266 | 267 | # By default, we strip descriptions when merging multiple configs; 268 | # but that's just because we can't make that sane when a user is 269 | # merging configs at the command-line on an ad-hoc basis; in this 270 | # particular case, the loaded config *does* know what files are 271 | # being overlayed, so we should assume its description is OK. 272 | if "description" in loaded: 273 | merged._data["description"] = loaded["description"] 274 | 275 | return merged 276 | 277 | @property 278 | def description(self) -> Optional[str]: 279 | if "description" in self._data: 280 | return self._data["description"] 281 | 282 | return None 283 | 284 | @property 285 | def alignment_holes(self) -> Optional[AlignmentHolesJobSpec]: 286 | if "alignment_holes" not in self._data: 287 | return None 288 | return AlignmentHolesJobSpec(self._data["alignment_holes"]) 289 | 290 | @property 291 | def isolation_routing(self) -> Optional[IsolationRoutingJobSpec]: 292 | if "isolation_routing" not in self._data: 293 | return None 294 | return IsolationRoutingJobSpec(self._data["isolation_routing"]) 295 | 296 | @property 297 | def edge_cuts(self) -> Optional[BoardCutoutJobSpec]: 298 | if "edge_cuts" not in self._data: 299 | return None 300 | return BoardCutoutJobSpec(self._data["edge_cuts"]) 301 | 302 | @property 303 | def drill(self) -> Dict[str, DrillProfileSpec]: 304 | drill_range_specs = {} 305 | 306 | for name, data in self._data.get("drill", {}).items(): 307 | drill_range_specs[name] = DrillProfileSpec(data, name=name) 308 | 309 | return drill_range_specs 310 | 311 | @property 312 | def slot(self) -> Dict[str, SlotProfileSpec]: 313 | drill_range_specs = {} 314 | 315 | for name, data in self._data.get("slot", {}).items(): 316 | drill_range_specs[name] = SlotProfileSpec(data, name=name) 317 | 318 | return drill_range_specs 319 | 320 | def __add__(self, other: Config) -> Config: 321 | left = copy.deepcopy(self._data) 322 | right = other._data 323 | 324 | overwrite = ["alignment_holes", "isolation_routing", "edge_cuts"] 325 | merge = ["drill", "slot"] 326 | 327 | for key in overwrite: 328 | if key in right: 329 | left[key] = right[key] 330 | 331 | for key in merge: 332 | if key in right: 333 | left.setdefault(key, {}).update(right[key]) 334 | 335 | if "description" in left: 336 | del left["description"] 337 | 338 | return Config(left) 339 | 340 | 341 | def get_user_config_dir() -> str: 342 | return os.path.join(appdirs.user_config_dir("barbari", "coddingtonbear"), "configs") 343 | 344 | 345 | def get_environment_config_file_path() -> str: 346 | return os.path.join( 347 | appdirs.user_config_dir("barbari", "coddingtonbear"), "config.yaml" 348 | ) 349 | 350 | 351 | @dataclass 352 | class EnvironmentConfig: 353 | flatcam_path: Optional[str] = None 354 | python_bin: Optional[str] = None 355 | 356 | 357 | def get_environment_config() -> EnvironmentConfig: 358 | try: 359 | with open(get_environment_config_file_path(), "r") as inf: 360 | loaded = yaml.safe_load(inf) 361 | 362 | return EnvironmentConfig(**loaded) 363 | except FileNotFoundError: 364 | return EnvironmentConfig() 365 | 366 | 367 | def save_environment_config(cfg: EnvironmentConfig) -> None: 368 | with open(get_environment_config_file_path(), "w") as outf: 369 | yaml.safe_dump(asdict(cfg), outf) 370 | 371 | 372 | def get_default_config_dir() -> str: 373 | return os.path.join(os.path.dirname(__file__), "configs") 374 | 375 | 376 | def _get_config_path_map() -> Dict[str, str]: 377 | configs: Dict[str, str] = {} 378 | 379 | directories = [get_default_config_dir(), get_user_config_dir()] 380 | 381 | for directory in directories: 382 | if not os.path.exists(directory): 383 | continue 384 | 385 | for filename in os.listdir(directory): 386 | name, ext = os.path.splitext(filename) 387 | 388 | if ext in (".yaml", ".yml"): 389 | configs[name] = os.path.join(directory, filename) 390 | 391 | return configs 392 | 393 | 394 | def get_available_configs() -> List[str]: 395 | return list(_get_config_path_map().keys()) 396 | 397 | 398 | def get_config_by_name(name: str) -> Config: 399 | try: 400 | config_path = _get_config_path_map()[name] 401 | except KeyError: 402 | raise exceptions.ConfigNotFound(f"Config '{name}' not found") 403 | 404 | return Config.from_file(config_path) 405 | 406 | 407 | def get_merged_config(names: List[str]) -> Config: 408 | configs: List[Config] = [] 409 | 410 | for config_name in names: 411 | configs.append(get_config_by_name(config_name)) 412 | 413 | return sum(configs, Config({})) 414 | -------------------------------------------------------------------------------- /barbari/flatcam.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import Callable, Dict, Iterable, List, Mapping, Optional, Union 4 | 5 | from gerber import excellon 6 | 7 | from .gerbers import GerberProject 8 | from .config import ( 9 | Config, 10 | DrillHolesJobSpec, 11 | IsolationRoutingJobSpec, 12 | JobSpec, 13 | MillHolesJobSpec, 14 | MillSlotsJobSpec, 15 | ToolProfileSpec, 16 | ) 17 | from .constants import LayerType, FlatcamLayer 18 | 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class FlatcamProcess(object): 24 | def __init__(self, cmd, *args, **params): 25 | self._cmd = cmd 26 | self._args = args 27 | self._params = params 28 | 29 | def __str__(self): 30 | cmd_parts = [ 31 | self._cmd, 32 | *self._args, 33 | *[ 34 | "-{key} {value}".format( 35 | key=key, 36 | value=value, 37 | ) 38 | for key, value in self._params.items() 39 | ], 40 | ] 41 | 42 | return " ".join(cmd_parts) 43 | 44 | def get_layer_name(self, layer: Union[FlatcamLayer, str]): 45 | if isinstance(layer, FlatcamLayer): 46 | return layer.value 47 | 48 | return layer 49 | 50 | 51 | class FlatcamCNCJob(FlatcamProcess): 52 | def __init__( 53 | self, 54 | config: JobSpec, 55 | input_layer: Union[FlatcamLayer, str], 56 | output_layer: Union[FlatcamLayer, str], 57 | ): 58 | extra_kwargs = {} 59 | if config.multi_depth or config.depth_per_pass: 60 | extra_kwargs["dpp"] = config.depth_per_pass 61 | 62 | super().__init__( 63 | "cncjob", 64 | self.get_layer_name(input_layer), 65 | z_cut=config.cut_z, 66 | z_move=config.travel_z, 67 | feedrate=config.feed_rate, 68 | dia=config.tool_size, 69 | spindlespeed=config.spindle_speed, 70 | **extra_kwargs, 71 | outname=self.get_layer_name(output_layer) 72 | ) 73 | 74 | 75 | class FlatcamDrillCNCJob(FlatcamProcess): 76 | def __init__( 77 | self, 78 | config: DrillHolesJobSpec, 79 | input_layer: Union[FlatcamLayer, str], 80 | output_layer: Union[FlatcamLayer, str], 81 | drilled_dias: List[float], 82 | ): 83 | super().__init__( 84 | "drillcncjob", 85 | self.get_layer_name(input_layer), 86 | drilled_dias=",".join(str(dia) for dia in drilled_dias), 87 | drillz=config.drill_z, 88 | travelz=config.travel_z, 89 | feedrate_z=config.feed_rate, 90 | spindlespeed=config.spindle_speed, 91 | outname=self.get_layer_name(output_layer), 92 | ) 93 | 94 | 95 | class FlatcamMillHoles(FlatcamProcess): 96 | def __init__( 97 | self, 98 | config: MillHolesJobSpec, 99 | input_layer: Union[FlatcamLayer, str], 100 | output_layer: Union[FlatcamLayer, str], 101 | milled_dias: List[float], 102 | ): 103 | super().__init__( 104 | "milldrills", 105 | self.get_layer_name(input_layer), 106 | tooldia=config.tool_size, 107 | milled_dias=",".join(str(dia) for dia in milled_dias), 108 | outname=self.get_layer_name(output_layer), 109 | ) 110 | 111 | 112 | class FlatcamMillSlots(FlatcamProcess): 113 | def __init__( 114 | self, 115 | config: MillSlotsJobSpec, 116 | input_layer: Union[FlatcamLayer, str], 117 | output_layer: Union[FlatcamLayer, str], 118 | milled_dias: List[float], 119 | ): 120 | super().__init__( 121 | "millslots", 122 | self.get_layer_name(input_layer), 123 | tooldia=config.tool_size, 124 | milled_dias=",".join(str(dia) for dia in milled_dias), 125 | outname=self.get_layer_name(output_layer), 126 | ) 127 | 128 | 129 | class FlatcamIsolate(FlatcamProcess): 130 | def __init__( 131 | self, 132 | config: IsolationRoutingJobSpec, 133 | input_layer: Union[FlatcamLayer, str], 134 | output_layer: Union[FlatcamLayer, str], 135 | ): 136 | super().__init__( 137 | "isolate", 138 | self.get_layer_name(input_layer), 139 | dia=config.tool_size, 140 | passes=int(config.passes), 141 | overlap=config.pass_overlap, 142 | combine=1, 143 | outname=self.get_layer_name(output_layer), 144 | ) 145 | 146 | 147 | class FlatcamWriteGcode(FlatcamProcess): 148 | def __init__( 149 | self, 150 | layer: Union[FlatcamLayer, str], 151 | path: str, 152 | counter: int, 153 | name: str, 154 | tool_name: str, 155 | tool_size: float, 156 | ): 157 | super().__init__( 158 | "write_gcode", 159 | self.get_layer_name(layer), 160 | os.path.join( 161 | path, 162 | "{counter}.{name}.{tool_size}.{tool_name}.gcode".format( 163 | counter=str(counter).zfill(2), 164 | name=name, 165 | tool_name=tool_name, 166 | tool_size=tool_size, 167 | ), 168 | ), 169 | ) 170 | 171 | 172 | class FlatcamProjectGenerator(object): 173 | def __init__(self, gerbers: GerberProject, config: Config): 174 | self._gerbers = gerbers 175 | self._config = config 176 | self._gcode_counter = 0 177 | 178 | super().__init__() 179 | 180 | @property 181 | def counter(self) -> int: 182 | self._gcode_counter += 1 183 | return self._gcode_counter 184 | 185 | @property 186 | def counter_str(self) -> str: 187 | return str(self.counter).zfill(2) 188 | 189 | @property 190 | def config(self) -> Config: 191 | return self._config 192 | 193 | @property 194 | def gerbers(self) -> GerberProject: 195 | return self._gerbers 196 | 197 | def _load_layers(self) -> Iterable[FlatcamProcess]: 198 | for layer_type, layer in self.gerbers.get_layers().items(): 199 | if layer_type == LayerType.DRILL: 200 | yield FlatcamProcess( 201 | "open_excellon", 202 | layer.filename, 203 | outname=layer_type.value, 204 | ) 205 | else: 206 | yield FlatcamProcess( 207 | "open_gerber", 208 | layer.filename, 209 | outname=layer_type.value, 210 | ) 211 | 212 | def _alignment_holes(self) -> Iterable[FlatcamProcess]: 213 | if not self.config.alignment_holes: 214 | return 215 | 216 | logger.debug("Processing alignment holes...") 217 | 218 | layers = self.gerbers.get_layers() 219 | 220 | edge_cuts = layers[LayerType.EDGE_CUTS] 221 | 222 | min_x = edge_cuts.bounds[0][0] 223 | max_x = edge_cuts.bounds[0][1] 224 | min_y = edge_cuts.bounds[1][0] 225 | max_y = edge_cuts.bounds[1][1] 226 | 227 | hole_offset = ( 228 | self.config.alignment_holes.hole_size / 2 229 | ) + self.config.alignment_holes.hole_offset 230 | holes = [ 231 | ( 232 | min_x + hole_offset, 233 | min_y - hole_offset, 234 | ), 235 | ( 236 | max_x - hole_offset, 237 | min_y - hole_offset, 238 | ), 239 | ] 240 | 241 | # This command took some trial and error to figure out -- 242 | # the "holes" parameter is undocumented, and I was only 243 | # able to figure out how to set the rotation point by 244 | # reading the source :shrug: 245 | yield FlatcamProcess( 246 | "aligndrill", 247 | FlatcamLayer.EDGE_CUTS.value, 248 | axis=self.config.alignment_holes.mirror_axis, 249 | dia=self.config.alignment_holes.hole_size, 250 | holes='"' + ",".join(str(hole) for hole in holes) + '"', 251 | dist=max_y / 2, 252 | ) 253 | yield FlatcamProcess( 254 | "mirror", 255 | FlatcamLayer.B_CU.value, 256 | axis=self.config.alignment_holes.mirror_axis, 257 | box="edge_cuts", 258 | ) 259 | yield FlatcamMillHoles( 260 | self.config.alignment_holes, 261 | FlatcamLayer.ALIGNMENT, 262 | FlatcamLayer.ALIGNMENT_PATH, 263 | milled_dias=[self.config.alignment_holes.hole_size], 264 | ) 265 | yield FlatcamCNCJob( 266 | self.config.alignment_holes, 267 | FlatcamLayer.ALIGNMENT_PATH, 268 | FlatcamLayer.ALIGNMENT_CNC, 269 | ) 270 | yield FlatcamWriteGcode( 271 | FlatcamLayer.ALIGNMENT_CNC, 272 | self.gerbers.path, 273 | self.counter, 274 | "alignment_holes", 275 | "end_mill", 276 | self.config.alignment_holes.tool_size, 277 | ) 278 | 279 | def _copper(self) -> Iterable[FlatcamProcess]: 280 | if not self.config.isolation_routing: 281 | return 282 | 283 | logger.debug("Processing isolation routing...") 284 | 285 | yield FlatcamIsolate( 286 | self.config.isolation_routing, 287 | FlatcamLayer.B_CU, 288 | FlatcamLayer.B_CU_PATH, 289 | ) 290 | yield FlatcamCNCJob( 291 | self.config.isolation_routing, 292 | FlatcamLayer.B_CU_PATH, 293 | FlatcamLayer.B_CU_CNC, 294 | ) 295 | yield FlatcamWriteGcode( 296 | FlatcamLayer.B_CU_CNC, 297 | self.gerbers.path, 298 | self.counter, 299 | "b_cu", 300 | "engraving_bit", 301 | self.config.isolation_routing.tool_size, 302 | ) 303 | yield FlatcamIsolate( 304 | self.config.isolation_routing, 305 | FlatcamLayer.F_CU, 306 | FlatcamLayer.F_CU_PATH, 307 | ) 308 | yield FlatcamCNCJob( 309 | self.config.isolation_routing, 310 | FlatcamLayer.F_CU_PATH, 311 | FlatcamLayer.F_CU_CNC, 312 | ) 313 | yield FlatcamWriteGcode( 314 | FlatcamLayer.F_CU_CNC, 315 | self.gerbers.path, 316 | self.counter, 317 | "f_cu", 318 | "engraving_bit", 319 | self.config.isolation_routing.tool_size, 320 | ) 321 | 322 | def _get_spec_for_tool( 323 | self, tool, specs: Mapping[str, ToolProfileSpec] 324 | ) -> Optional[str]: 325 | selected: Optional[str] = None 326 | 327 | for spec_name, spec in specs.items(): 328 | if spec.allowed_for_tool_size(tool.diameter) and spec.is_better_match_than( 329 | tool.diameter, specs[selected] if selected else None 330 | ): 331 | selected = spec_name 332 | 333 | return selected 334 | 335 | def _get_tool_hit_count( 336 | self, 337 | layer: excellon.ExcellonFile, 338 | tool: excellon.ExcellonTool, 339 | slot: bool = False, 340 | ): 341 | counter = 0 342 | 343 | expected_class = excellon.DrillSlot if slot else excellon.DrillHit 344 | 345 | for hit in layer.hits: 346 | if isinstance(hit, expected_class) and hit.tool == tool: 347 | counter += 1 348 | 349 | return counter 350 | 351 | def _drill(self) -> Iterable[FlatcamProcess]: 352 | if not self.config.drill: 353 | return 354 | 355 | logger.debug("Processing drills...") 356 | 357 | layer: excellon.ExcellonFile = self.gerbers.get_layers()[LayerType.DRILL] 358 | 359 | process_map: Dict[str, List[int]] = {} 360 | for tool_number, tool in layer.tools.items(): 361 | if not self._get_tool_hit_count(layer, tool): 362 | logger.debug( 363 | "Tool %s (%s dia) has no drill hits.", 364 | tool_number, 365 | tool.diameter, 366 | ) 367 | continue 368 | 369 | selected_spec = self._get_spec_for_tool(tool, self.config.drill) 370 | if selected_spec: 371 | logger.debug( 372 | "Assigning tool %s (%s dia) to drill process %s.", 373 | tool_number, 374 | tool.diameter, 375 | selected_spec, 376 | ) 377 | process_map.setdefault(selected_spec, []).append(tool_number) 378 | else: 379 | logger.error( 380 | "Unable to find compatible drill profile for tool " 381 | "#%s having diameter %s; omitting from output.", 382 | tool_number, 383 | tool.diameter, 384 | ) 385 | 386 | for process_name, tool_numbers in process_map.items(): 387 | specs = self.config.drill[process_name].specs 388 | for idx, spec in enumerate(specs): 389 | layer_name = "drill_{name}_{idx}".format( 390 | name=process_name, 391 | idx=idx, 392 | ) 393 | if isinstance(spec, DrillHolesJobSpec): 394 | yield FlatcamDrillCNCJob( 395 | spec, 396 | FlatcamLayer.DRILL, 397 | layer_name, 398 | [layer.tools[n].diameter for n in tool_numbers], 399 | ) 400 | yield FlatcamWriteGcode( 401 | layer_name, 402 | self.gerbers.path, 403 | self.counter, 404 | "drill_{name}".format(name=process_name), 405 | "drill", 406 | spec.tool_size, 407 | ) 408 | elif isinstance(spec, MillHolesJobSpec): 409 | yield FlatcamMillHoles( 410 | spec, 411 | FlatcamLayer.DRILL, 412 | layer_name, 413 | [layer.tools[n].diameter for n in tool_numbers], 414 | ) 415 | yield FlatcamCNCJob(spec, layer_name, layer_name + "_cnc") 416 | yield FlatcamWriteGcode( 417 | layer_name + "_cnc", 418 | self.gerbers.path, 419 | self.counter, 420 | "drill_{name}".format(name=process_name), 421 | "end_mill", 422 | spec.tool_size, 423 | ) 424 | else: 425 | raise ValueError("Unhandled spec!") 426 | 427 | def _slot(self) -> Iterable[FlatcamProcess]: 428 | if not self.config.slot: 429 | return 430 | 431 | logger.debug("Processing slots...") 432 | 433 | layer: excellon.ExcellonFile = self.gerbers.get_layers()[LayerType.DRILL] 434 | 435 | process_map: Dict[str, List[int]] = {} 436 | for tool_number, tool in layer.tools.items(): 437 | if not self._get_tool_hit_count(layer, tool, slot=True): 438 | logger.debug( 439 | "Tool %s (%s dia) has no slot hits.", 440 | tool_number, 441 | tool.diameter, 442 | ) 443 | continue 444 | 445 | selected_spec = self._get_spec_for_tool(tool, self.config.slot) 446 | if selected_spec: 447 | logger.debug( 448 | "Assigning tool %s (%s dia) to slot process %s.", 449 | tool_number, 450 | tool.diameter, 451 | selected_spec, 452 | ) 453 | process_map.setdefault(selected_spec, []).append(tool_number) 454 | else: 455 | logger.error( 456 | "Unable to find compatible slot profile for tool " 457 | "#%s having diameter %s; omitting from output.", 458 | tool_number, 459 | tool.diameter, 460 | ) 461 | 462 | for process_name, tool_numbers in process_map.items(): 463 | specs = self.config.slot[process_name].specs 464 | for idx, spec in enumerate(specs): 465 | layer_name = "slot_{name}_{idx}".format( 466 | name=process_name, 467 | idx=idx, 468 | ) 469 | assert isinstance(spec, MillSlotsJobSpec) 470 | 471 | yield FlatcamMillSlots( 472 | spec, 473 | FlatcamLayer.DRILL, 474 | layer_name, 475 | [layer.tools[n].diameter for n in tool_numbers], 476 | ) 477 | yield FlatcamCNCJob(spec, layer_name, layer_name + "_cnc") 478 | yield FlatcamWriteGcode( 479 | layer_name + "_cnc", 480 | self.gerbers.path, 481 | self.counter, 482 | "slot_{name}".format(name=process_name), 483 | "end_mill", 484 | spec.tool_size, 485 | ) 486 | 487 | def _edge_cuts(self) -> Iterable[FlatcamProcess]: 488 | if not self.config.edge_cuts: 489 | return 490 | 491 | logger.debug("Processing edge cuts...") 492 | 493 | yield FlatcamProcess( 494 | "cutout", 495 | FlatcamLayer.EDGE_CUTS.value, 496 | dia=self.config.edge_cuts.tool_size, 497 | margin=self.config.edge_cuts.margin, 498 | gapsize=self.config.edge_cuts.gap_size, 499 | gaps=self.config.edge_cuts.gaps, 500 | ) 501 | yield FlatcamCNCJob( 502 | self.config.edge_cuts, 503 | FlatcamLayer.EDGE_CUTS_PATH, 504 | FlatcamLayer.EDGE_CUTS_CNC, 505 | ) 506 | yield FlatcamWriteGcode( 507 | FlatcamLayer.EDGE_CUTS_CNC, 508 | self.gerbers.path, 509 | self.counter, 510 | "edge_cuts", 511 | "end_mill", 512 | self.config.edge_cuts.tool_size, 513 | ) 514 | 515 | def _quit(self) -> Iterable[FlatcamProcess]: 516 | yield FlatcamProcess("quit_flatcam") 517 | 518 | def get_cnc_processes(self) -> Iterable[FlatcamProcess]: 519 | major_step_generators: List[Callable[[], Iterable[FlatcamProcess]]] = [ 520 | self._load_layers, 521 | self._alignment_holes, 522 | self._copper, 523 | self._drill, 524 | self._slot, 525 | self._edge_cuts, 526 | self._quit, 527 | ] 528 | 529 | for major_step in major_step_generators: 530 | for step in major_step(): 531 | logger.debug("Step %s generated", step) 532 | yield step 533 | --------------------------------------------------------------------------------