├── drawings ├── __init__.py ├── base_drawing.py ├── svg_drawing.py └── dxf_drawing.py ├── requirements.txt ├── docs ├── images │ ├── logo.png │ ├── no_assembly.jpg │ ├── assembly_chair.jpg │ ├── assembly_racks.jpg │ ├── assembly_stool.jpg │ ├── screw_lengths.jpg │ ├── stool_layout.jpg │ ├── youtube_teaser.png │ ├── pine_craft_cube.jpg │ ├── pine_craft_intro.jpg │ ├── accessories_stand.jpg │ ├── assembly_lifehacks.jpg │ ├── assembly_projector.jpg │ ├── assembly_stool_v2.jpg │ └── assembly_shelf_over_commode.jpg └── examples.md ├── .gitignore ├── constants └── constants.py ├── examples ├── advanced_features │ ├── placing.yaml │ └── advanced_features.sh ├── universal_kit │ ├── placing_boxes.yaml │ ├── placing_beams_and_plates.yaml │ └── universal_kit.sh ├── simplest │ ├── placing.yaml │ ├── simplest.sh │ └── simplest.bat └── stool_kit │ ├── stool.sh │ └── stool.yaml ├── masters ├── base_master.py ├── cut_length.py ├── place_parts.py ├── gen_box.py └── gen_part.py ├── utils ├── utils.py └── custom_arg_parser.py ├── install.sh ├── LICENCE ├── pine-craft.py ├── tools ├── part_drawer.py ├── pattern_drawer.py └── parts_placer.py └── README.md /drawings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyyaml 2 | svgwrite 3 | ezdxf 4 | numpy 5 | scipy 6 | argcomplete -------------------------------------------------------------------------------- /docs/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.idea 2 | **__pycache__ 3 | venv 4 | examples/**/out 5 | examples/**/out2 6 | examples/**/out3 -------------------------------------------------------------------------------- /docs/images/no_assembly.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/no_assembly.jpg -------------------------------------------------------------------------------- /docs/images/assembly_chair.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_chair.jpg -------------------------------------------------------------------------------- /docs/images/assembly_racks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_racks.jpg -------------------------------------------------------------------------------- /docs/images/assembly_stool.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_stool.jpg -------------------------------------------------------------------------------- /docs/images/screw_lengths.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/screw_lengths.jpg -------------------------------------------------------------------------------- /docs/images/stool_layout.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/stool_layout.jpg -------------------------------------------------------------------------------- /docs/images/youtube_teaser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/youtube_teaser.png -------------------------------------------------------------------------------- /docs/images/pine_craft_cube.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/pine_craft_cube.jpg -------------------------------------------------------------------------------- /docs/images/pine_craft_intro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/pine_craft_intro.jpg -------------------------------------------------------------------------------- /docs/images/accessories_stand.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/accessories_stand.jpg -------------------------------------------------------------------------------- /docs/images/assembly_lifehacks.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_lifehacks.jpg -------------------------------------------------------------------------------- /docs/images/assembly_projector.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_projector.jpg -------------------------------------------------------------------------------- /docs/images/assembly_stool_v2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_stool_v2.jpg -------------------------------------------------------------------------------- /docs/images/assembly_shelf_over_commode.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikitalogos/pine_craft/HEAD/docs/images/assembly_shelf_over_commode.jpg -------------------------------------------------------------------------------- /constants/constants.py: -------------------------------------------------------------------------------- 1 | MATERIAL_THICKNESS_MM = 6 2 | HOLE_DIAMETER_MM = 4 3 | UNIT_SIZE_MM = 30 4 | FILLET_RADIUS_MM = 5 5 | HOLES_NUM = 4 6 | HOLES_RING_RADIUS_NORM = 0.5 -------------------------------------------------------------------------------- /examples/advanced_features/placing.yaml: -------------------------------------------------------------------------------- 1 | work_area: 2 | width_mm: 1500 3 | height_mm: 750 4 | parts: 5 | # paths should be specified as absolute or as relative to this file 6 | 7 | # ~~~~beams~~~~ 8 | - path: "out/beam_3" 9 | number: 500 -------------------------------------------------------------------------------- /examples/universal_kit/placing_boxes.yaml: -------------------------------------------------------------------------------- 1 | work_area: 2 | width_mm: 1500 3 | height_mm: 750 4 | parts: 5 | # ~~~~box parts~~~~ 6 | # one box requires part_a x2 and part_b x2 7 | - path: "out/parts/box_part_a" 8 | number: 625 9 | - path: "out/parts/box_part_b" 10 | number: 625 -------------------------------------------------------------------------------- /masters/base_master.py: -------------------------------------------------------------------------------- 1 | from argparse import Namespace 2 | from utils.custom_arg_parser import CustomArgParser 3 | 4 | 5 | class BaseMaster: 6 | @staticmethod 7 | def make_subparser(parser: CustomArgParser): 8 | pass 9 | 10 | @staticmethod 11 | def run(args: Namespace): 12 | pass 13 | -------------------------------------------------------------------------------- /examples/simplest/placing.yaml: -------------------------------------------------------------------------------- 1 | work_area: 2 | width_mm: 1500 3 | height_mm: 750 4 | parts: 5 | # paths should be specified as absolute or as relative to this file 6 | 7 | # ~~~~beams~~~~ 8 | - path: "out/beam_3" 9 | number: 50 10 | - path: "out/beam_5_sparse" 11 | number: 60 12 | - path: "out/plate_10x10_sparse" 13 | number: 25 14 | 15 | # one box requires part_a x2 and part_b x2 16 | - path: "out/box_part_a" 17 | number: 120 18 | - path: "out/box_part_b" 19 | number: 120 -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def make_dir_with_user_ask(dir_path): 5 | if os.path.exists(dir_path): 6 | print(f'{dir_path} exists! Do you want to overwrite it? y/n:') 7 | while True: 8 | inp = input() 9 | if inp == 'n': 10 | print('Abort!') 11 | exit(0) 12 | elif inp == 'y': 13 | break 14 | else: 15 | print('Invalid choice. Please type y or n:') 16 | continue 17 | else: 18 | os.makedirs(dir_path) 19 | -------------------------------------------------------------------------------- /examples/simplest/simplest.sh: -------------------------------------------------------------------------------- 1 | rm -r out 2 | 3 | echo "~~~~~~~~~~Creating regular parts~~~~~~~~~~" 4 | pine-craft gen-part -o out/beam_3 -w 1 -t 3 5 | pine-craft gen-part -o out/beam_5_sparse -w 1 -t 5 -p "x:1 y:2" 6 | pine-craft gen-part -o out/plate_10x10_sparse -w 10 -t 10 -p "x:4,1 y:4,1" 7 | 8 | echo "~~~~~~~~~~Creating box parts~~~~~~~~~~" 9 | pine-craft gen-box -o out 10 | 11 | echo "~~~~~~~~~~Place parts on sheet for CNC cutting~~~~~~~~~~" 12 | pine-craft place-parts -i placing.yaml -o out 13 | 14 | echo "~~~~~~~~~~Compute total cutting length~~~~~~~~~~" 15 | pine-craft cut-length -i out/placing/placing.dxf -------------------------------------------------------------------------------- /examples/stool_kit/stool.sh: -------------------------------------------------------------------------------- 1 | rm -r out 2 | mkdir out 3 | cd out || exit 4 | 5 | # ~~~~~~~~~~~~~~~~generate parts~~~~~~~~~~~~~~~~~~~~~~ 6 | mkdir parts 7 | cd parts || exit 8 | 9 | # all beams 10 | pine-craft gen-part -o beam_3 -w 1 -t 3 11 | pine-craft gen-part -o beam_5 -w 1 -t 5 12 | pine-craft gen-part -o beam_10 -w 1 -t 10 13 | pine-craft gen-part -o beam_14 -w 1 -t 14 14 | 15 | # sparse plates 16 | pine-craft gen-part -o plate_10x10_sparse -w 10 -t 10 -p "x:4,1 y:4,1" 17 | 18 | # box parts 19 | pine-craft gen-box 20 | 21 | # ~~~~~~~~~~~~~~~~place parts~~~~~~~~~~~~~~~~~~~~~~ 22 | cd ../.. 23 | 24 | pine-craft place-parts -i stool.yaml -o out -------------------------------------------------------------------------------- /utils/custom_arg_parser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | 4 | class CustomArgParser(argparse.ArgumentParser): 5 | def add_argument(self, *args, **kwargs): 6 | if '-h' not in args: 7 | is_required = kwargs.get('required', False) 8 | default = kwargs.get('default', None) 9 | help = kwargs.get('help', '') 10 | 11 | default_str = '' 12 | if default is not None: 13 | default_str = f", default: {default}" 14 | 15 | help += f' ({"required" if is_required else "optional"}{default_str})' 16 | kwargs['help'] = help 17 | 18 | super().add_argument(*args, **kwargs) -------------------------------------------------------------------------------- /examples/stool_kit/stool.yaml: -------------------------------------------------------------------------------- 1 | work_area: 2 | width_mm: 1500 3 | height_mm: 750 4 | parts: 5 | # paths should be specified as absolute or as relative to this file 6 | 7 | # ~~~~beams~~~~ 8 | - path: "out/parts/beam_3" 9 | number: 12 10 | - path: "out/parts/beam_5" 11 | number: 8 12 | - path: "out/parts/beam_10" 13 | number: 8 14 | - path: "out/parts/beam_14" 15 | number: 8 16 | 17 | # ~~~~sparse plates~~~~ 18 | - path: "out/parts/plate_10x10_sparse" 19 | number: 2 20 | 21 | # ~~~~box parts~~~~ 22 | # one box requires part_a x2 and part_b x2 23 | - path: "out/parts/box_part_a" 24 | number: 16 25 | - path: "out/parts/box_part_b" 26 | number: 16 -------------------------------------------------------------------------------- /examples/simplest/simplest.bat: -------------------------------------------------------------------------------- 1 | call ..\..\venv\Scripts\activate.bat 2 | 3 | rd /s /q out 4 | 5 | echo '~~~~~~~~Creating regular parts~~~~~~' 6 | py ..\..\pine-craft.py gen-part -o out/beam_3 -w 1 -t 3 7 | py ..\..\pine-craft.py gen-part -o out/beam_5_sparse -w 1 -t 5 -p "x:1 y:2" 8 | py ..\..\pine-craft.py gen-part -o out/plate_10x10_sparse -w 10 -t 10 -p "x:4,1 y:4,1" 9 | 10 | echo '~~~~~~Creating box parts~~~~~~' 11 | py ..\..\pine-craft.py gen-box -o out 12 | 13 | echo '~~~~~~Place parts on sheet for CNC cutting~~~~~~' 14 | py ..\..\pine-craft.py place-parts -i placing.yaml -o out 15 | 16 | echo '~~~~~~Compute total cutting length~~~~~~~~' 17 | py ..\..\pine-craft.py cut-length -i out/placing/placing.dxf -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "~~~~~~~~~~~~~~~Installing python3.9~~~~~~~~~~~~~~~" 4 | sudo apt-get install python3.9 python3.9-venv 5 | 6 | echo "~~~~~~~~~~~~~~~Creating python3 virtual environment~~~~~~~~~~~~~~~" 7 | rm -r venv 8 | python3.9 -m venv venv 9 | venv/bin/pip install --upgrade pip && venv/bin/pip install -r requirements.txt 10 | 11 | echo "~~~~~~~~~~~~~~~Enabling autocompletion - https://pypi.org/project/argcomplete/ ~~~~~~~~~~~~~~~" 12 | sudo venv/bin/activate-global-python-argcomplete 13 | 14 | EXE="/usr/bin/pine-craft" 15 | 16 | echo "~~~~~~~~~~~~~~~Creating executable '${EXE}'~~~~~~~~~~~~~~~" 17 | echo "#!/bin/bash 18 | # PYTHON_ARGCOMPLETE_OK 19 | $(realpath .)/venv/bin/python $(realpath .)/pine-craft.py \"\$@\"" | sudo tee ${EXE} 20 | sudo chmod a+x ${EXE} 21 | echo "~~~~~~~~~~~~~~~Done!~~~~~~~~~~~~~~~" -------------------------------------------------------------------------------- /masters/cut_length.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | from argparse import Namespace 3 | 4 | from drawings.dxf_drawing import DxfDrawing 5 | from masters.base_master import BaseMaster 6 | from utils.custom_arg_parser import CustomArgParser 7 | 8 | 9 | class CutLength(BaseMaster): 10 | @classmethod 11 | def make_subparser(cls, parser: CustomArgParser): 12 | parser.add_argument( 13 | '-i', 14 | '--input', 15 | type=str, 16 | required=True, 17 | help='Input file in DXF format', 18 | ) 19 | 20 | @staticmethod 21 | def run(args: Namespace): 22 | path = args.input 23 | 24 | extension = os.path.splitext(path)[-1] 25 | if extension.lower() != '.dxf': 26 | print("Only .dxf files are supported!") 27 | exit(1) 28 | 29 | dwg = DxfDrawing(file_path=path) 30 | len_m = dwg.get_total_lines_length_mm() / 1000 31 | print(f'Cut length = {len_m:.3f} m') 32 | 33 | -------------------------------------------------------------------------------- /drawings/base_drawing.py: -------------------------------------------------------------------------------- 1 | class BaseDrawing: 2 | """Base class for vector drawing. 3 | Supports 4 | - creation of instance (container) 5 | - adding elements to container 6 | - saving to file""" 7 | 8 | DEFAULT_COLOR = 'black' 9 | 10 | def line(self, p0, p1, color=None): 11 | """Create a line by two points""" 12 | pass 13 | 14 | def circle(self, center, diameter, color=None): 15 | """Create a circle by center and diameter""" 16 | pass 17 | 18 | def polygon_filled(self, points, color=None): 19 | """Create polygon defined by its boundary. 20 | Polygon here means "closed shape with piecewise linear boundary without self-intersections or nested shapes" 21 | Polygon has only fill, not stroke.""" 22 | pass 23 | 24 | def arc_csa(self, center, start, angle_deg, color=None): 25 | """Create arc defined by it's center, start point and sweep angle""" 26 | pass 27 | 28 | def write(self, file, is_no_ext=False): 29 | """Save drawing to file. Filename can be passed with or without extension.""" 30 | pass 31 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Nikita Logos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/universal_kit/placing_beams_and_plates.yaml: -------------------------------------------------------------------------------- 1 | work_area: 2 | width_mm: 1500 3 | height_mm: 750 4 | parts: 5 | # paths should be specified as absolute or as relative to this file 6 | 7 | # ~~~~beams~~~~ 8 | - path: "out/parts/beam_1" 9 | number: 50 10 | - path: "out/parts/beam_2" 11 | number: 60 12 | - path: "out/parts/beam_3" 13 | number: 25 14 | - path: "out/parts/beam_5" 15 | number: 25 16 | - path: "out/parts/beam_7" 17 | number: 50 18 | - path: "out/parts/beam_10" 19 | number: 50 20 | - path: "out/parts/beam_14" 21 | number: 50 22 | - path: "out/parts/beam_20" 23 | number: 50 24 | 25 | 26 | # ~~~~small plates~~~~ 27 | - path: "out/parts/plate_2x2" 28 | number: 30 29 | - path: "out/parts/plate_2x3" 30 | number: 20 31 | - path: "out/parts/plate_2x4" 32 | number: 20 33 | - path: "out/parts/plate_3x3" 34 | number: 20 35 | 36 | # ~~~~sparse plates~~~~ 37 | - path: "out/parts/plate_10x10_sparse" 38 | number: 10 39 | - path: "out/parts/plate_10x15_sparse" 40 | number: 4 41 | - path: "out/parts/plate_10x20_sparse" 42 | number: 6 43 | - path: "out/parts/plate_10x30_sparse" 44 | number: 4 -------------------------------------------------------------------------------- /pine-craft.py: -------------------------------------------------------------------------------- 1 | # PYTHON_ARGCOMPLETE_OK 2 | 3 | 4 | import argcomplete 5 | from typing import NamedTuple 6 | 7 | from masters.base_master import BaseMaster 8 | from masters.cut_length import CutLength 9 | from masters.gen_box import GenBox 10 | from masters.gen_part import GenPart 11 | from masters.place_parts import PlaceParts 12 | 13 | from utils.custom_arg_parser import CustomArgParser 14 | 15 | 16 | class Master(NamedTuple): 17 | master: BaseMaster = None 18 | name: str = None 19 | description: str = '' 20 | 21 | 22 | MASTERS = [ 23 | Master(CutLength(), 'cut-length', 'Compute total curves length in a file'), 24 | Master(GenBox(), 'gen-box', 'Generate box parts A and B'), 25 | Master(GenPart(), 'gen-part', 'Part generator'), 26 | Master(PlaceParts(), 'place-parts', 'Parts placer. Arranges parts in efficient way for manufacturing on CNC.'), 27 | ] 28 | 29 | 30 | if __name__ == '__main__': 31 | parser = CustomArgParser(description='Pine Craft SW library') 32 | subparsers = parser.add_subparsers(dest="master") 33 | 34 | for master in MASTERS: 35 | subparser = subparsers.add_parser(master.name, description=master.description) 36 | master.master.make_subparser(subparser) 37 | 38 | argcomplete.autocomplete(parser) 39 | args = parser.parse_args() 40 | 41 | for master in MASTERS: 42 | if master.name == args.master: 43 | master.master.run(args) 44 | exit(0) 45 | 46 | print(parser.format_help()) 47 | exit(1) 48 | -------------------------------------------------------------------------------- /examples/universal_kit/universal_kit.sh: -------------------------------------------------------------------------------- 1 | rm -r out 2 | mkdir out 3 | cd out || exit 4 | 5 | # ~~~~~~~~~~~~~~~~generate parts~~~~~~~~~~~~~~~~~~~~~~ 6 | mkdir parts 7 | cd parts || exit 8 | 9 | # all beams 10 | pine-craft gen-part -o beam_1 -w 1 -t 1 11 | pine-craft gen-part -o beam_2 -w 1 -t 2 12 | pine-craft gen-part -o beam_3 -w 1 -t 3 13 | pine-craft gen-part -o beam_5 -w 1 -t 5 14 | pine-craft gen-part -o beam_7 -w 1 -t 7 15 | pine-craft gen-part -o beam_10 -w 1 -t 10 16 | pine-craft gen-part -o beam_14 -w 1 -t 14 17 | pine-craft gen-part -o beam_20 -w 1 -t 20 18 | 19 | # dense plates 20 | pine-craft gen-part -o plate_2x2 -w 2 -t 2 21 | pine-craft gen-part -o plate_5x10 -w 5 -t 10 22 | pine-craft gen-part -o plate_10x10 -w 10 -t 10 23 | pine-craft gen-part -o plate_10x15 -w 10 -t 15 24 | pine-craft gen-part -o plate_10x20 -w 10 -t 20 25 | 26 | # sparse plates 27 | pine-craft gen-part -o plate_10x10_sparse -w 10 -t 10 -p "x:4,1 y:4,1" 28 | pine-craft gen-part -o plate_10x15_sparse -w 10 -t 15 -p "x:4,1 y:4,1" 29 | pine-craft gen-part -o plate_10x20_sparse -w 10 -t 20 -p "x:4,1 y:4,1" 30 | pine-craft gen-part -o plate_10x30_sparse -w 10 -t 30 -p "x:4,1 y:4,1" 31 | 32 | # experimental plates 33 | pine-craft gen-part -o plate_2x3 -w 2 -t 3 34 | pine-craft gen-part -o plate_2x4 -w 2 -t 4 35 | pine-craft gen-part -o plate_3x3 -w 3 -t 3 36 | 37 | # box parts 38 | pine-craft gen-box 39 | 40 | # ~~~~~~~~~~~~~~~~place parts~~~~~~~~~~~~~~~~~~~~~~ 41 | cd ../.. 42 | 43 | pine-craft place-parts -i placing_beams_and_plates.yaml -o out 44 | pine-craft place-parts -i placing_boxes.yaml -o out -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | ## Pine Craft in action or Just add some screws :) 2 | 3 | ### Stool 4 | 5 | This stool is one of my first designs from Pine Craft. I have special feelings to it for its simplicity and elegance. The stool turned out to be very durable, you can not only sit on it, but also step up with your feet to explore the upper shelves :) 6 | 7 | ![Stool](images/assembly_stool.jpg) 8 | 9 | ### Stool v2 10 | 11 | Improved version with longer beams for legs. 12 | 13 | ![Stool](images/assembly_stool_v2.jpg) 14 | 15 | ### Chair 16 | 17 | If you add a back to the stool, you will get a chair! The rigid back helps to keep your posture, and also serves as an excellent clothes hanger :) 18 | 19 | ![Stool](images/assembly_chair.jpg) 20 | 21 | 22 | ### Racks 23 | 24 | It is especially easy to make racks from Pine Craft. On the left there is a rack for containers with tools, in the middle there is a shoe rack, and on the right there is a rack for small things: 25 | 26 | ![Racks](images/assembly_racks.jpg) 27 | 28 | ### Shelf over the commode 29 | 30 | The surface of the commode can be increased if you put such a shelf on it. 31 | 32 | ![Shelf over commode](images/assembly_shelf_over_commode.jpg) 33 | 34 | ### Cute accessories holder 35 | 36 | Add some Pine Craft to your boring white wall :) 37 | 38 | ![Accessories stand](images/accessories_stand.jpg) 39 | 40 | ### Life Hacks 41 | 42 | 1. In a rented apartment there was a computer desk with a very uncomfortable cutout for a monitor. I had to modify it a little... 43 | 44 | 2. The design of the bed was poor as well. Instead of having a flat headboard so that the bad could be pushed tightly against the wall, for some reason it had arched one. As a result, there was a large gap in which things were constantly falling through. However, it was not so difficult to fix it) 45 | 46 | 3. The baseboard prevented moving the charging station of the robot vacuum cleaner to the wall, and every time the robot arrived at the base, he tilted it and as a result could not recharge. A small spacer from the constructor corrected the situation. 47 | 48 | 4. After relocation, we bought a temporary scratching post for our cat. However, it turned out to be very unstable - when the cat was leaning on it with the whole mass, it would turn over. Pine Craft plates added the necessary weight and support area to the base of the scratching post. The cat is happy :) 49 | 50 | ![Lifehacks](images/assembly_lifehacks.jpg) 51 | 52 | The projector stand allows you to direct its beam vertically upwards. Oh yeah, now you can watch your favorite movies right on the ceiling! 53 | 54 | ![Projector](images/assembly_projector.jpg) 55 | 56 | Some parts of the constructor are useful on their own, even without assembly. For example, long beams can be used to get objects out from under the bed, and the plates work great as a laptop base! 57 | 58 | ![No assembly](images/no_assembly.jpg) -------------------------------------------------------------------------------- /tools/part_drawer.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from collections.abc import Sequence 3 | 4 | from drawings.base_drawing import BaseDrawing 5 | from drawings.svg_drawing import SvgDrawing 6 | from drawings.dxf_drawing import DxfDrawing 7 | 8 | 9 | class PartDrawer: 10 | def __init__(self, shape_wh, unit_size, pattern_drawers=(), fillet_radius=5, drawings: Sequence[BaseDrawing] = None): 11 | if drawings is None: 12 | drawings = [ 13 | SvgDrawing(), 14 | DxfDrawing(), 15 | ] 16 | 17 | self.drawings = drawings 18 | 19 | self.shape_wh = shape_wh 20 | self.unit_size = unit_size 21 | self.pattern_drawers = pattern_drawers 22 | self.fillet_radius = fillet_radius 23 | 24 | def _draw_border(self, drawing): 25 | w, h = self.shape_wh 26 | w_mm = w * self.unit_size 27 | h_mm = h * self.unit_size 28 | fr = self.fillet_radius 29 | 30 | d = drawing 31 | 32 | # ~~~lines~~~ 33 | # top 34 | d.line( 35 | (fr, 0), 36 | (w_mm - fr, 0) 37 | ) 38 | # bottom 39 | d.line( 40 | (fr, h_mm), 41 | (w_mm - fr, h_mm) 42 | ) 43 | # left 44 | d.line( 45 | (0, fr), 46 | (0, h_mm - fr) 47 | ) 48 | # right 49 | d.line( 50 | (w_mm, fr), 51 | (w_mm, h_mm - fr) 52 | ) 53 | 54 | # ~~~fillets~~~ 55 | # top-left 56 | d.arc_csa( 57 | (fr, fr), 58 | (0, fr), 59 | 90 60 | ) 61 | # bottom-left 62 | d.arc_csa( 63 | (fr, h_mm - fr), 64 | (fr, h_mm), 65 | 90 66 | ) 67 | # botom-right 68 | d.arc_csa( 69 | (w_mm - fr, h_mm - fr), 70 | (w_mm - fr, h_mm), 71 | -90 72 | ) 73 | # top-right 74 | d.arc_csa( 75 | (w_mm - fr, fr), 76 | (w_mm, fr), 77 | -90 78 | ) 79 | 80 | def draw(self): 81 | for drawing in self.drawings: 82 | self._draw_border(drawing) 83 | for pd in self.pattern_drawers: 84 | pd.draw(drawing) 85 | 86 | def get_meta_dict(self): 87 | return { 88 | 'shape_wh': self.shape_wh, 89 | 'unit_size': self.unit_size, 90 | 'pattern_drawers': [p.get_meta_dict() for p in self.pattern_drawers], 91 | 'fillet_radius': self.fillet_radius, 92 | } 93 | 94 | def write(self, file): 95 | for drawing in self.drawings: 96 | drawing.write(file, is_no_ext=True) 97 | 98 | with open(f'{file}.yaml', 'w') as outf: 99 | yaml.safe_dump( 100 | self.get_meta_dict(), 101 | outf, 102 | ) 103 | -------------------------------------------------------------------------------- /examples/advanced_features/advanced_features.sh: -------------------------------------------------------------------------------- 1 | rm -r out 2 | rm -r out2 3 | rm -r out3 4 | 5 | 6 | echo "~~~~~~~~~~~~~~~~~~~~GEN-BOX~~~~~~~~~~~~~~~~~~~~" 7 | echo "~~~~~~~~~~Let's double the box dimensions:~~~~~~~~~~" 8 | pine-craft gen-box -o out --unit-size 60 --material-thickness 12 --hole-diameter 8 9 | 10 | 11 | echo "~~~~~~~~~~~~~~~~~~~~GEN-PART~~~~~~~~~~~~~~~~~~~~" 12 | echo "~~~~~~~~~~Let's scale part up by 1.25:~~~~~~~~~~" 13 | pine-craft gen-part -o out/beam_5 -w 1 -t 5 --unit-size 37.5 --fillet-radius 6.25 --hole-diameter 5.01 14 | 15 | echo "~~~~~~~~~~Make circular part with 6 holes:~~~~~~~~~~" 16 | pine-craft gen-part -o out/circular_1 -w 1 -t 1 --fillet-radius 15 --holes-num 6 17 | 18 | echo "~~~~~~~~~~Make part with big holes in the middle of units:~~~~~~~~~~" 19 | pine-craft gen-part -o out/beam_10_big_holes -w 1 -t 10 --holes-num 1 --hole-diameter 10 --holes-ring-radius-norm 0 20 | 21 | echo "~~~~~~~~~~Rotate holes at 45°:~~~~~~~~~~" 22 | pine-craft gen-part -o out/beam_2_45_deg -w 1 -t 2 --first-hole-angle-deg 45 23 | 24 | echo "~~~~~~~~~~Change pattern so that holes will go every second unit:~~~~~~~~~~" 25 | pine-craft gen-part -o out/beam_5_sparse -w 1 -t 5 -p "x:1 y:2" 26 | 27 | 28 | 29 | echo "~~~~~~~~~~Define pattern directly row by row. 'o' means holes, '.' means no holes:~~~~~~~~~~" 30 | pine-craft gen-part -o out/plate_3x4_direct_pattern -w 3 -t 4 -p "ooo o.. oo. o.o" 31 | 32 | 33 | 34 | echo "~~~~~~~~~~To make two patterns at the same time, pass pattern-specific parameters twice:~~~~~~~~~~" 35 | pine-craft gen-part -o out/plate_5x5_2_patterns -w 5 -t 5 -p "x:4 y:4" -p "..... ..... ..o.. ..... ....." 36 | 37 | echo "~~~~~~~~~~Let's make one regular pattern and one for big central holes in every second unit:~~~~~~~~~~" 38 | pine-craft gen-part -o out/beam_5_2_patterns -w 1 -t 5 \ 39 | --holes-num 4 --holes-ring-radius-norm 0.5 --hole-diameter 4 -p "x:1 y:1" \ 40 | --holes-num 1 --holes-ring-radius-norm 0.0 --hole-diameter 6 -p "x:1 y:2" 41 | 42 | 43 | echo "~~~~~~~~~~If you specify several patterns, pattern-specific parameters can be defined once if they don't change:~~~~~~~~~~" 44 | pine-craft gen-part -o out/beam_15_2_patterns -w 1 -t 15 --holes-num 2 \ 45 | --first-hole-angle-deg 90 -p "x:1 y:2" \ 46 | --first-hole-angle-deg 0 -p "x:1 y:3" 47 | 48 | echo "~~~~~~~~~~If some of patter-specific parameters is passed less times than the number of patterns, last patterns will use the last value of this parameter:~~~~~~~~~~" 49 | pine-craft gen-part -o out/beam_4_4_patterns -w 1 -t 4 \ 50 | -p "o..." --holes-num 1 --hole-diameter 2 \ 51 | -p ".o.." --holes-num 2 --hole-diameter 4 \ 52 | -p "..o." --holes-num 3 \ 53 | -p "...o" \ 54 | 55 | 56 | echo "~~~~~~~~~~~~~~~~~~~~PLACE-PARTS~~~~~~~~~~~~~~~~~~~~" 57 | echo "~~~~~~~~~~Place parts in a regular way~~~~~~~~~~" 58 | pine-craft gen-part -o out/beam_3 -w 1 -t 3 59 | pine-craft place-parts -i placing.yaml -o out 60 | pine-craft cut-length -i out/placing/placing.dxf 61 | 62 | echo "~~~~~~~~~~Place parts without deduplication: cut length is much bigger!~~~~~~~~~~" 63 | pine-craft place-parts -i placing.yaml -o out2 --no-deduplicate 64 | pine-craft cut-length -i out2/placing/placing.dxf 65 | 66 | echo "~~~~~~~~~~Place parts with smaller hv_ratio: layout will change~~~~~~~~~~" 67 | pine-craft place-parts -i placing.yaml -o out3 --hv-ratio 5 68 | -------------------------------------------------------------------------------- /drawings/svg_drawing.py: -------------------------------------------------------------------------------- 1 | import os 2 | import svgwrite 3 | import numpy as np 4 | 5 | from .base_drawing import BaseDrawing 6 | 7 | 8 | class SvgDrawing(BaseDrawing): 9 | def __init__(self): 10 | dwg = svgwrite.Drawing() 11 | 12 | self.dwg = dwg 13 | 14 | def _color(self, color_str): 15 | if color_str is None: 16 | color_str = self.DEFAULT_COLOR 17 | 18 | if color_str == 'black': 19 | return svgwrite.rgb(0, 0, 0) 20 | elif color_str == 'red': 21 | return svgwrite.rgb(255, 0, 0) 22 | elif color_str == 'green': 23 | return svgwrite.rgb(0, 255, 0) 24 | else: 25 | raise Exception(f'Unsupported color: {color_str}!') 26 | 27 | def line(self, p0, p1, color=None): 28 | # invert y axis 29 | # p0 = np.array(p0) 30 | # p1 = np.array(p1) 31 | # 32 | # p0[1] *= -1 33 | # p1[1] *= -1 34 | # --- 35 | 36 | self.dwg.add( 37 | self.dwg.line(p0, p1, stroke=self._color(color)) 38 | ) 39 | 40 | def circle(self, center, diameter, color=None): 41 | # invert y axis 42 | # center = np.array(center) 43 | # center[1] *= -1 44 | # --- 45 | 46 | self.dwg.add( 47 | self.dwg.circle( 48 | center=center, 49 | r=diameter / 2, 50 | fill='none', 51 | stroke=self._color(color), 52 | ) 53 | ) 54 | 55 | def polygon_filled(self, points, color=None): 56 | # invert y axis 57 | # points = np.array(points) 58 | # points[:, 1] *= -1 59 | # --- 60 | 61 | self.dwg.add( 62 | svgwrite.shapes.Polygon( 63 | points=points, 64 | fill=self._color(color), 65 | stroke='none', 66 | ) 67 | ) 68 | 69 | def arc_csa(self, center, start, angle_deg, color=None): 70 | # invert y axis 71 | center = np.array(center) 72 | start = np.array(start) 73 | # 74 | # center[1] *= -1 75 | # start[1] *= -1 76 | # angle_deg *= -1 77 | # --- 78 | 79 | path = svgwrite.path.Path( 80 | d=('M', start[0], start[1]), 81 | fill='none', 82 | stroke=self._color(color), 83 | ) 84 | 85 | fi = np.deg2rad(angle_deg) 86 | R = np.array([ 87 | [np.cos(fi), -np.sin(fi)], 88 | [np.sin(fi), np.cos(fi)], 89 | ]) 90 | end = center + np.matmul(R, (start - center)) 91 | end = end.tolist() 92 | 93 | radius = np.linalg.norm(start - center) 94 | 95 | path.push_arc( 96 | target=end, 97 | rotation=0, 98 | r=radius, 99 | large_arc=abs(angle_deg) > 180, 100 | angle_dir='+' if angle_deg > 0 else '-', 101 | absolute=True 102 | ) 103 | 104 | self.dwg.add( 105 | path 106 | ) 107 | 108 | def write(self, file, is_no_ext=False): 109 | if is_no_ext: 110 | file += '.svg' 111 | else: 112 | _, ext = os.path.splitext(file) 113 | assert ext == '.svg' 114 | 115 | with open(file, 'w') as outf: 116 | self.dwg.write(outf, pretty=True, indent=2) -------------------------------------------------------------------------------- /tools/pattern_drawer.py: -------------------------------------------------------------------------------- 1 | import re 2 | import numpy as np 3 | 4 | 5 | class PatternDrawer: 6 | def __init__(self, pattern_str, shape_wh, unit_size, first_hole_angle_deg=0, 7 | holes_num=4, hole_diameter=4, holes_ring_radius_norm=0.5): 8 | self.shape_wh = shape_wh 9 | self.unit_size = unit_size 10 | self.first_hole_angle_deg = first_hole_angle_deg 11 | self.holes_num = holes_num 12 | self.hole_diameter = hole_diameter 13 | self.holes_ring_radius_norm = holes_ring_radius_norm 14 | 15 | self.pattern_mask = self._parse_pattern_str(pattern_str) 16 | 17 | def _parse_pattern_str(self, pattern_str): 18 | pattern_str = pattern_str.strip() 19 | 20 | mask = np.zeros(self.shape_wh, dtype=bool) 21 | 22 | res = re.fullmatch('[o. ]+', pattern_str) 23 | if res is not None: 24 | pattern_str = pattern_str.replace(' ', '') 25 | 26 | w, h = self.shape_wh 27 | size = w * h 28 | 29 | pattern_len = len(pattern_str) 30 | if pattern_len != size: 31 | print(f'Wrong pattern string length! Expected {w}*{h}={size} symbols, found {pattern_len}') 32 | return None 33 | 34 | for i in range(w): 35 | for j in range(h): 36 | idx = i + j * w 37 | char = pattern_str[idx] 38 | mask[i, j] = char == 'o' 39 | 40 | return mask 41 | 42 | comma_separated_numbers = '[0-9]+(,[0-9]+)*' 43 | regex = f'x:{comma_separated_numbers}\s+y:{comma_separated_numbers}' 44 | res = re.fullmatch(regex, pattern_str) 45 | if res is not None: 46 | x, y = pattern_str.split() 47 | 48 | x_steps = x[2:].split(',') 49 | x_steps = [int(s) for s in x_steps] 50 | 51 | y_steps = y[2:].split(',') 52 | y_steps = [int(s) for s in y_steps] 53 | 54 | def make_valid_line(length, steps): 55 | valid_line = np.zeros(length, dtype=bool) 56 | step_idx = 0 57 | idx = 0 58 | while idx < len(valid_line): 59 | valid_line[idx] = True 60 | idx += steps[step_idx] 61 | step_idx = (step_idx + 1) % len(steps) 62 | 63 | return valid_line 64 | 65 | w, h = self.shape_wh 66 | valid_x = make_valid_line( 67 | length=w, 68 | steps=x_steps 69 | ) 70 | valid_y = make_valid_line( 71 | length=h, 72 | steps=y_steps 73 | ) 74 | 75 | mask[:] = True 76 | mask[~valid_x, :] = False 77 | mask[:, ~valid_y] = False 78 | return mask 79 | 80 | raise Exception(f'Unrecognized pattern: "{pattern_str}"') 81 | 82 | def _draw_circle_group(self, drawing, x_mm, y_mm): 83 | angles = np.deg2rad(self.first_hole_angle_deg) + \ 84 | np.linspace(0, 2 * np.pi, self.holes_num, endpoint=False) 85 | 86 | us_half = self.unit_size / 2 87 | for angle in angles: 88 | x = us_half * (1 + np.cos(angle) * self.holes_ring_radius_norm) 89 | y = us_half * (1 - np.sin(angle) * self.holes_ring_radius_norm) 90 | 91 | drawing.circle( 92 | (x_mm + x, y_mm + y), 93 | self.hole_diameter, 94 | color='red', 95 | ) 96 | 97 | def get_meta_dict(self): 98 | return { 99 | 'shape_wh': self.shape_wh, 100 | 'unit_size': self.unit_size, 101 | 'first_hole_angle_deg': self.first_hole_angle_deg, 102 | 'holes_num': self.holes_num, 103 | 'hole_diameter': self.hole_diameter, 104 | 'holes_ring_radius_norm': self.holes_ring_radius_norm 105 | } 106 | 107 | def draw(self, drawing): 108 | w, h = self.shape_wh 109 | us = self.unit_size 110 | 111 | for i in range(w): 112 | for j in range(h): 113 | if self.pattern_mask[i, j]: 114 | self._draw_circle_group( 115 | drawing, 116 | x_mm=us * i, 117 | y_mm=us * j, 118 | ) 119 | -------------------------------------------------------------------------------- /masters/place_parts.py: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | "exec" "`dirname $0`/../venv/bin/python" "$0" "$@" 3 | 4 | import os 5 | import yaml 6 | import numpy as np 7 | 8 | 9 | import os.path 10 | from argparse import Namespace 11 | 12 | from masters.base_master import BaseMaster 13 | from tools.parts_placer import PartsPlacer, Part 14 | from utils.utils import make_dir_with_user_ask 15 | from utils.custom_arg_parser import CustomArgParser 16 | 17 | 18 | class PlaceParts(BaseMaster): 19 | @classmethod 20 | def make_subparser(cls, parser: CustomArgParser): 21 | parser.add_argument( 22 | '-i', 23 | '--input', 24 | type=str, 25 | required=True, 26 | help='Source file with instructions in YAML format', 27 | ) 28 | parser.add_argument( 29 | '-o', 30 | '--output', 31 | type=str, 32 | default='.', 33 | help='Path to output directory. Results will be placed in subdirectory of this directory', 34 | ) 35 | parser.add_argument( 36 | '--hv-ratio', 37 | type=float, 38 | default=1e5, 39 | help='Ratio between vertical and horizontal alignment of parts', 40 | ) 41 | parser.add_argument( 42 | '--no-deduplicate', 43 | action='store_true', 44 | help='Disable deduplication (optimization of the cut length by removing duplicate lines)', 45 | ) 46 | parser.add_argument( 47 | '-v', 48 | '--verbose', 49 | action='store_true', 50 | help='Show parts placing order.', 51 | ) 52 | 53 | @staticmethod 54 | def run(args: Namespace): 55 | name = os.path.basename(os.path.splitext(args.input)[0]) 56 | print(f'Generating placing "{name}"...') 57 | 58 | # validate input 59 | with open(args.input, 'r') as inf: 60 | data = yaml.safe_load(inf) 61 | 62 | parts = None 63 | work_area_wh = None 64 | unit_size = None 65 | try: 66 | work_area_wh_mm = np.array([ 67 | data['work_area']['width_mm'], 68 | data['work_area']['height_mm'], 69 | ]) 70 | 71 | parts = [] 72 | unit_sizes = [] 73 | for part in data['parts']: 74 | part_dir = part['path'] 75 | part_name = part_dir.split('/')[-1] 76 | 77 | if not os.path.isabs(part_dir): 78 | config_file_dir = os.path.dirname(args.input) 79 | part_dir = os.path.join( 80 | config_file_dir, 81 | part_dir 82 | ) 83 | part_path = f'{part_dir}/{part_name}.yaml' 84 | 85 | with open(part_path, 'r') as inf: 86 | part_meta = yaml.safe_load(inf) 87 | 88 | parts.append( 89 | Part( 90 | name=part_name, 91 | path_no_ext=os.path.splitext(part_path)[0], 92 | shape_wh=part_meta['shape_wh'], 93 | number=part['number'], 94 | ) 95 | ) 96 | unit_sizes.append(part_meta['unit_size']) 97 | 98 | assert len(np.unique(np.array(unit_sizes))) == 1, \ 99 | 'Placing together parts with different unit sizes is not supported!' 100 | 101 | unit_size = unit_sizes[0] 102 | work_area_wh = (work_area_wh_mm // unit_size).astype(int).tolist() 103 | except Exception as e: 104 | print('Error while processing config file:', e) 105 | exit(1) 106 | 107 | # prepare output directory 108 | base_dir = f'{args.output}/{name}' 109 | make_dir_with_user_ask(base_dir) 110 | 111 | # place parts 112 | placer = PartsPlacer( 113 | parts=parts, 114 | work_area_wh=work_area_wh, 115 | unit_size=unit_size, 116 | h_to_v_coef_ratio=args.hv_ratio, 117 | verbose=args.verbose 118 | ) 119 | placer.place() 120 | placer.draw() 121 | 122 | if not args.no_deduplicate: 123 | dwg = placer.dxf_drawing 124 | old_len_m = dwg.get_total_lines_length_mm() / 1000 125 | dwg.deduplicate() 126 | new_len_m = dwg.get_total_lines_length_mm() / 1000 127 | print(f'Deduplication done! Cut length optimized: {old_len_m:.3f}m -> {new_len_m:.3f}m') 128 | 129 | placer.write(file_no_ext=f'{base_dir}/{name}') 130 | -------------------------------------------------------------------------------- /masters/gen_box.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import os 3 | import yaml 4 | from argparse import Namespace 5 | 6 | from drawings.base_drawing import BaseDrawing 7 | from drawings.svg_drawing import SvgDrawing 8 | from drawings.dxf_drawing import DxfDrawing 9 | from masters.base_master import BaseMaster 10 | from utils.custom_arg_parser import CustomArgParser 11 | from constants.constants import UNIT_SIZE_MM, HOLE_DIAMETER_MM, MATERIAL_THICKNESS_MM 12 | 13 | 14 | class GenBox(BaseMaster): 15 | def __init__(self): 16 | self.output: str = None 17 | self.unit_size: float = None 18 | self.hole_diameter: float = None 19 | self.material_thickness: float = None 20 | 21 | @classmethod 22 | def make_subparser(cls, parser: CustomArgParser): 23 | parser.add_argument( 24 | '-o', 25 | '--output', 26 | type=str, 27 | default='.', 28 | help='Path to output directory. Results will be placed in subdirectories of this directory', 29 | ) 30 | 31 | parser.add_argument( 32 | '--unit-size', 33 | type=float, 34 | default=UNIT_SIZE_MM, 35 | help='Unit size in mm', 36 | ) 37 | parser.add_argument( 38 | '--hole-diameter', 39 | type=float, 40 | default=HOLE_DIAMETER_MM, 41 | help='Diameter of hole in mm', 42 | ) 43 | parser.add_argument( 44 | '--material-thickness', 45 | type=float, 46 | default=MATERIAL_THICKNESS_MM, 47 | help='Thickness of the material in mm', 48 | ) 49 | 50 | def _make_holes(self, drawing: BaseDrawing): 51 | """Draw screw holes""" 52 | circles = [ 53 | [ 54 | self.unit_size * 0.5, 55 | self.unit_size * 0.25 56 | ], 57 | [ 58 | self.unit_size * 0.5, 59 | self.unit_size * 0.75 60 | ], 61 | ] 62 | for circle in circles: 63 | drawing.circle( 64 | center=circle, 65 | diameter=self.hole_diameter, 66 | color='red', 67 | ) 68 | 69 | def _make_symmetric_shape(self, drawing: BaseDrawing, path_rel_to_center: np.ndarray): 70 | """Given a path in the I-st quadrant, will mirror it horizontally and vertically and then draw""" 71 | for x_mirr in [-1, 1]: 72 | for y_mirr in [-1, 1]: 73 | for i in range(len(path_rel_to_center) - 1): 74 | line = path_rel_to_center[i:i + 2].copy() 75 | 76 | line[:, 0] *= x_mirr 77 | line[:, 1] *= y_mirr 78 | line += self.unit_size * 0.5 79 | 80 | drawing.line( 81 | p0=line[0].tolist(), 82 | p1=line[1].tolist() 83 | ) 84 | 85 | def _part_a(self, drawing: BaseDrawing): 86 | u50 = self.unit_size * 0.5 87 | u25 = self.unit_size * 0.25 88 | m = self.material_thickness 89 | path = np.array([ 90 | [u50, 0], 91 | [u50, u50 - m], 92 | [u25, u50 - m], 93 | [u25, u50], 94 | [0, u50] 95 | ]) 96 | self._make_symmetric_shape( 97 | drawing=drawing, 98 | path_rel_to_center=path 99 | ) 100 | self._make_holes(drawing) 101 | 102 | def _part_b(self, drawing: BaseDrawing): 103 | u50 = self.unit_size * 0.5 104 | u25 = self.unit_size * 0.25 105 | m = self.material_thickness 106 | path = np.array([ 107 | [u50 - m, 0], 108 | [u50 - m, u25], 109 | [u50, u25], 110 | [u50, u50], 111 | [0, u50] 112 | ]) 113 | self._make_symmetric_shape( 114 | drawing=drawing, 115 | path_rel_to_center=path 116 | ) 117 | self._make_holes(drawing) 118 | 119 | # polygon 120 | h50 = self.hole_diameter * 0.5 121 | points = np.array([ 122 | [u50 - m, h50], 123 | [u50 - m, -h50], 124 | [m - u50, -h50], 125 | [m - u50, h50], 126 | ]) 127 | points += u50 128 | drawing.polygon_filled( 129 | points=points.tolist(), 130 | color='green', 131 | ) 132 | 133 | def _part(self, is_part_a: bool): 134 | drawings = [ 135 | SvgDrawing(), 136 | DxfDrawing() 137 | ] 138 | extensions = [ 139 | '.svg', 140 | '.dxf' 141 | ] 142 | for idx, zipped in enumerate(zip(drawings, extensions)): 143 | drawing, extension = zipped 144 | if is_part_a: 145 | self._part_a(drawing) 146 | else: 147 | self._part_b(drawing) 148 | 149 | name = f'box_part_{"a" if is_part_a else "b"}' 150 | base_dir = f'{self.output}/{name}' 151 | os.makedirs(base_dir, exist_ok=True) 152 | drawing.write(f'{base_dir}/{name}{extension}') 153 | 154 | # write metadata 155 | if idx == 0: 156 | data = { 157 | "shape_wh": [ 158 | 1, 1 159 | ], 160 | "unit_size": self.unit_size, 161 | } 162 | with open(f'{base_dir}/{name}.yaml', 'w') as outf: 163 | yaml.safe_dump(data, outf) 164 | 165 | def run(self, args: Namespace): 166 | print('Generating box parts...') 167 | 168 | self.output = args.output 169 | self.unit_size = args.unit_size 170 | self.hole_diameter = args.hole_diameter 171 | self.material_thickness = args.material_thickness 172 | 173 | self._part(is_part_a=True) 174 | self._part(is_part_a=False) 175 | -------------------------------------------------------------------------------- /masters/gen_part.py: -------------------------------------------------------------------------------- 1 | import os 2 | import argparse 3 | from argparse import Namespace 4 | 5 | from constants.constants import UNIT_SIZE_MM, HOLE_DIAMETER_MM, FILLET_RADIUS_MM, HOLES_NUM, HOLES_RING_RADIUS_NORM 6 | 7 | from masters.base_master import BaseMaster 8 | from tools.pattern_drawer import PatternDrawer 9 | from tools.part_drawer import PartDrawer 10 | from utils.utils import make_dir_with_user_ask 11 | from utils.custom_arg_parser import CustomArgParser 12 | 13 | 14 | def _ranged_type(value_type, min_value, max_value): 15 | """ 16 | source: https://stackoverflow.com/questions/55324449/how-to-specify-a-minimum-or-maximum-float-value-with-argparse 17 | """ 18 | 19 | def range_checker(arg: str): 20 | try: 21 | f = value_type(arg) 22 | except ValueError: 23 | raise argparse.ArgumentTypeError(f'must be a valid {value_type}') 24 | if f < min_value or f > max_value: 25 | raise argparse.ArgumentTypeError(f'must be within [{min_value}, {max_value}]') 26 | return f 27 | 28 | return range_checker 29 | 30 | 31 | class GenPart(BaseMaster): 32 | @classmethod 33 | def make_subparser(cls, parser: CustomArgParser): 34 | parser.add_argument( 35 | '-o', 36 | '--output', 37 | type=str, 38 | required=True, 39 | help='Path to output directory. Results will be placed it that directory ' 40 | 'and named after the last directory in the path', 41 | ) 42 | 43 | parser.add_argument( 44 | '-w', 45 | '--width', 46 | type=int, 47 | required=True, 48 | help='Part width in units', 49 | ) 50 | parser.add_argument( 51 | '-t', 52 | '--height', 53 | type=int, 54 | required=True, 55 | help='Part height in units', 56 | ) 57 | 58 | parser.add_argument( 59 | '--unit-size', 60 | type=float, 61 | default=UNIT_SIZE_MM, 62 | help='Unit size in mm', 63 | ) 64 | parser.add_argument( 65 | '--fillet-radius', 66 | type=float, 67 | default=FILLET_RADIUS_MM, 68 | help='Radius of fillet on corners in mm', 69 | ) 70 | 71 | # these arguments specify a pattern. They can be called multiple times 72 | parser.add_argument( 73 | '-p', 74 | '--pattern', 75 | type=str, 76 | default=['x:1 y:1'], 77 | help='String that defines units pattern. There are two ways to define pattern:\n' 78 | '1. "x:2 y:3,1" - define strides per x and per y\n' 79 | '2. "ooo o.. oo. o.o" - define whether to put holes in this unit or not row by row.\n' 80 | ' "o" means yes, "." means no.\n' 81 | ' Spaces are optional but are useful to visually split rows to not to make a mistake.', 82 | action='append', 83 | ) 84 | parser.add_argument( 85 | '--first-hole-angle-deg', 86 | type=float, 87 | default=[0.0], 88 | help='Angle of first hole from zero in degrees (counterclockwise, zero is from the right)', 89 | action='append', 90 | ) 91 | parser.add_argument( 92 | '--holes-num', 93 | type=int, 94 | default=[HOLES_NUM], 95 | help='Number of holes', 96 | action='append', 97 | ) 98 | parser.add_argument( 99 | '--holes-ring-radius-norm', 100 | type=_ranged_type(float, 0.0, 2**0.5), 101 | default=[HOLES_RING_RADIUS_NORM], 102 | help='Radius of circular pattern where holes will be placed. Normalized to the "unit-size". ' 103 | '1.0 means half of "unit-size". Should be a float in range 0.0..1.41', 104 | action='append', 105 | ) 106 | parser.add_argument( 107 | '--hole-diameter', 108 | type=float, 109 | default=[HOLE_DIAMETER_MM], 110 | help='Diameter of hole in mm', 111 | action='append', 112 | ) 113 | 114 | @staticmethod 115 | def run(args: Namespace): 116 | output_name = os.path.basename(args.output) 117 | print(f'Generating {output_name}...') 118 | 119 | # prepare output directory 120 | make_dir_with_user_ask(args.output) 121 | 122 | # generate part 123 | shape_wh = (args.width, args.height) 124 | 125 | pattern_params = dict( 126 | pattern_str=args.pattern, 127 | first_hole_angle_deg=args.first_hole_angle_deg, 128 | holes_num=args.holes_num, 129 | hole_diameter=args.hole_diameter, 130 | holes_ring_radius_norm=args.holes_ring_radius_norm, 131 | ) 132 | 133 | # if user passes at least one param - it should overwrite default param value 134 | for k, v in pattern_params.items(): 135 | if len(v) > 1: 136 | v = v[1:] 137 | pattern_params[k] = v 138 | 139 | patterns_num = max([len(v) for v in pattern_params.values()]) 140 | print(f'You specified {patterns_num} pattern(s)!') 141 | 142 | pattern_drawers = [] 143 | for idx in range(patterns_num): 144 | kwargs = { 145 | k: v[idx] if idx < len(v) else v[-1] for k, v in pattern_params.items() 146 | } 147 | print(f'Pattern {idx + 1}: {kwargs}') 148 | 149 | pattern_drawer = PatternDrawer( 150 | shape_wh=shape_wh, 151 | unit_size=args.unit_size, 152 | **kwargs, 153 | ) 154 | pattern_drawers.append(pattern_drawer) 155 | part_drawer = PartDrawer( 156 | shape_wh=shape_wh, 157 | unit_size=args.unit_size, 158 | pattern_drawers=pattern_drawers, 159 | fillet_radius=args.fillet_radius, 160 | ) 161 | part_drawer.draw() 162 | part_drawer.write(f'{args.output}/{output_name}') 163 | -------------------------------------------------------------------------------- /tools/parts_placer.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import yaml 3 | import scipy.signal 4 | import numpy as np 5 | from typing import Tuple, NamedTuple 6 | from dataclasses import dataclass 7 | from collections.abc import Sequence, MutableSequence 8 | 9 | from drawings.dxf_drawing import DxfDrawing 10 | 11 | 12 | @dataclass 13 | class Part: 14 | name: str 15 | path_no_ext: str 16 | shape_wh: Tuple[int, int] 17 | number: int 18 | 19 | 20 | class PartsPlacer: 21 | class PlacingCandidate(NamedTuple): 22 | name: str 23 | part_idx: int 24 | orientation_idx: int 25 | sheet_idx: int 26 | pos_xy: Tuple[int, int] 27 | score: float 28 | 29 | class PlacedPart(NamedTuple): 30 | part: Part 31 | angle_deg: int 32 | pos_xy: Tuple[int, int] 33 | 34 | def __init__( 35 | self, 36 | parts: Sequence[Part], 37 | work_area_wh: [int, int], 38 | unit_size: float = 1.0, 39 | h_to_v_coef_ratio: float = 1e5, 40 | verbose=False, 41 | ): 42 | for part in parts: 43 | assert part.number > 0, f'Incorrect number of parts for part "{part.name}": it should be >= 0' 44 | 45 | self.parts = parts 46 | self.work_area_wh = work_area_wh 47 | self.unit_size = unit_size 48 | self.h_to_v_coef_ratio = h_to_v_coef_ratio 49 | self.verbose = verbose 50 | 51 | parts_by_name = {} 52 | for part in parts: 53 | name = part.name 54 | assert name not in parts_by_name, 'Error: duplicate part names!' 55 | parts_by_name[name] = part 56 | self.parts_by_name = parts_by_name 57 | 58 | self.placed_parts = None 59 | self.sheets_number = 0 60 | 61 | self.dxf_drawing = DxfDrawing() 62 | 63 | def _initialize_score_map(self, sheet_idx): 64 | ww, hh = self.work_area_wh 65 | v_coef = 1.0 66 | h_coef = v_coef * self.h_to_v_coef_ratio 67 | 68 | # h-score 69 | h_score_max = ww * h_coef 70 | h_score_1d = np.linspace( 71 | h_score_max, 72 | h_score_max / 2, 73 | ww 74 | ) 75 | h_score = np.repeat( 76 | np.expand_dims(h_score_1d, axis=0), 77 | hh, 78 | axis=0 79 | ) 80 | 81 | # v-score 82 | v_score_max = ww * v_coef 83 | v_score_1d = np.linspace( 84 | v_score_max, 85 | 0, 86 | hh, 87 | ) 88 | v_score = np.repeat( 89 | np.expand_dims(v_score_1d, axis=1), 90 | ww, 91 | axis=1 92 | ) 93 | 94 | # total score 95 | score_map = h_score + v_score 96 | 97 | score_map /= 2.5**sheet_idx 98 | 99 | return score_map 100 | 101 | def place(self): 102 | ww, hh = self.work_area_wh 103 | 104 | score_maps_by_sheet = [ 105 | self._initialize_score_map(sheet_idx=0) 106 | ] 107 | parts_queue: MutableSequence[Part] = list(copy.deepcopy(self.parts)) 108 | placed_parts = [] 109 | idx = 0 110 | while True: 111 | if len(parts_queue) == 0: 112 | break 113 | 114 | placing_candidates = [] 115 | for part_idx, part in enumerate(parts_queue): 116 | pw, ph = part.shape_wh 117 | 118 | is_orient_ok = [ 119 | pw <= ww and ph <= hh, 120 | pw <= hh and ph <= ww, 121 | ] 122 | if np.sum(is_orient_ok) == 0: 123 | raise Exception( 124 | f'Part {part.name} does not fit in work area: {part.shape_wh} > {self.work_area_wh}' 125 | ) 126 | 127 | orientations = [] 128 | for i in range(len(is_orient_ok)): 129 | if not is_orient_ok[i]: 130 | continue 131 | 132 | part_wh = [[pw, ph], [ph, pw]][i] 133 | orientations.append({ 134 | 'orientation_idx': i, 135 | 'part_wh': part_wh, 136 | }) 137 | 138 | for orientation in orientations: 139 | orientation_idx = orientation['orientation_idx'] 140 | w, h = orientation['part_wh'] 141 | 142 | sheet_idx = 0 143 | while sheet_idx < len(score_maps_by_sheet): 144 | score_map = score_maps_by_sheet[sheet_idx] 145 | 146 | kernel = np.ones((h, w), dtype=float) 147 | scores = scipy.signal.convolve2d(score_map, kernel, 'valid') 148 | 149 | best_score = np.max(scores) 150 | 151 | sh, sw = scores.shape 152 | best_pos_1d = np.argmax(scores) 153 | best_pos_xy = ( 154 | best_pos_1d % sw, 155 | best_pos_1d // sw, 156 | ) 157 | 158 | if best_score == -np.inf: 159 | sheets_num = len(score_maps_by_sheet) 160 | is_last_sheet = sheet_idx == (sheets_num - 1) 161 | if is_last_sheet: 162 | score_maps_by_sheet.append( 163 | self._initialize_score_map(sheet_idx=sheets_num) 164 | ) 165 | else: 166 | candidate = self.PlacingCandidate( 167 | name=part.name, 168 | part_idx=part_idx, 169 | orientation_idx=orientation_idx, 170 | sheet_idx=sheet_idx, 171 | pos_xy=best_pos_xy, 172 | score=best_score, 173 | ) 174 | placing_candidates.append(candidate) 175 | 176 | sheet_idx += 1 177 | 178 | best_candidate = max(placing_candidates, key=lambda x: x.score) 179 | 180 | # ~~~~~~~~update everything~~~~~~~~~~ 181 | x, y = best_candidate.pos_xy 182 | w, h = self.parts_by_name[best_candidate.name].shape_wh 183 | if best_candidate.orientation_idx == 1: 184 | w, h = h, w 185 | score_map = score_maps_by_sheet[best_candidate.sheet_idx] 186 | score_map[y:y + h, x:x + w] = -np.inf 187 | 188 | part_idx = best_candidate.part_idx 189 | part = parts_queue[part_idx] 190 | part.number -= 1 191 | if part.number == 0: 192 | del parts_queue[part_idx] 193 | 194 | if self.verbose: 195 | print(f'{idx}. place {part.name}') 196 | idx += 1 197 | else: 198 | print('.', end='') 199 | # ~~~~~debug~~~~~~~ 200 | # import matplotlib.pyplot as plt 201 | # h, w = score_maps_by_sheet[0].shape 202 | # img = np.zeros((h, w * 6)) 203 | # for i in range(len(score_maps_by_sheet)): 204 | # img[:, i * w:(i + 1) * w] = score_maps_by_sheet[i] 205 | # plt.imshow(img) 206 | # plt.colorbar() 207 | # plt.show() 208 | # ~~~~~~~~ 209 | 210 | angle_deg = [0, -90][best_candidate.orientation_idx] 211 | w, h = part.shape_wh 212 | x, y = best_candidate.pos_xy 213 | dy = [0, w][best_candidate.orientation_idx] 214 | sheet_dx = ww * 1.5 * best_candidate.sheet_idx 215 | pos_xy = (x + sheet_dx, y + dy) 216 | placed_part = self.PlacedPart( 217 | part=part, 218 | angle_deg=angle_deg, 219 | pos_xy=pos_xy 220 | ) 221 | placed_parts.append(placed_part) 222 | 223 | if not self.verbose: 224 | print() 225 | 226 | self.placed_parts = placed_parts 227 | self.sheets_number = len(score_maps_by_sheet) 228 | 229 | def draw(self): 230 | for part in self.placed_parts: 231 | translate_xy = np.array(part.pos_xy) * self.unit_size 232 | 233 | self.dxf_drawing.subdrawing( 234 | subdrawing_file=part.part.path_no_ext, 235 | translate_xy=translate_xy, 236 | rotate_deg=part.angle_deg, 237 | is_no_ext=True 238 | ) 239 | 240 | def write(self, file_no_ext): 241 | self.dxf_drawing.write(file_no_ext, is_no_ext=True) 242 | 243 | total_length_mm = self.dxf_drawing.get_total_lines_length_mm() 244 | total_length_m = total_length_mm / 1000 245 | total_length_m = round(total_length_m, 3) 246 | data = { 247 | 'work_area_wh': self.work_area_wh, 248 | 'unit_size': self.unit_size, 249 | 'sheets_number': self.sheets_number, 250 | 'total_length_m': total_length_m 251 | } 252 | print() 253 | print(yaml.safe_dump(data)) 254 | with open(f'{file_no_ext}.yaml', 'w') as outf: 255 | yaml.safe_dump(data, outf) 256 | 257 | 258 | -------------------------------------------------------------------------------- /drawings/dxf_drawing.py: -------------------------------------------------------------------------------- 1 | import os 2 | import numpy as np 3 | import ezdxf 4 | import math 5 | from typing import NamedTuple 6 | 7 | from .base_drawing import BaseDrawing 8 | 9 | 10 | class DxfDrawing(BaseDrawing): 11 | EPSILON = 1e-5 12 | 13 | def __init__(self, file_path=None): 14 | if file_path is None: 15 | dxf = ezdxf.new() 16 | dxf.units = ezdxf.units.MM 17 | else: 18 | dxf = ezdxf.readfile(file_path) 19 | 20 | msp = dxf.modelspace() 21 | 22 | self.dxf = dxf 23 | self.msp = msp 24 | 25 | def _color(self, color: str or int): 26 | if type(color) == int: 27 | return color 28 | 29 | if color is None: 30 | color = self.DEFAULT_COLOR 31 | 32 | if color == 'black': 33 | return ezdxf.enums.ACI.BLACK 34 | elif color == 'red': 35 | return ezdxf.enums.ACI.RED 36 | elif color == 'green': 37 | return ezdxf.enums.ACI.GREEN 38 | else: 39 | raise Exception(f'Unsupported color: {color}!') 40 | 41 | def line(self, p0, p1, color=None): 42 | self.msp.add_line( 43 | p0, 44 | p1, 45 | dxfattribs={ 46 | "color": self._color(color) 47 | } 48 | ) 49 | 50 | def circle(self, center, diameter, color=None): 51 | self.msp.add_circle( 52 | center=center, 53 | radius=diameter / 2, 54 | dxfattribs={ 55 | "color": self._color(color) 56 | } 57 | ) 58 | 59 | def polygon_filled(self, points, color=None): 60 | hatch = self.msp.add_hatch( 61 | color=self._color(color) 62 | ) 63 | hatch.paths.add_polyline_path( 64 | points, 65 | is_closed=True 66 | ) 67 | 68 | def arc_csa(self, center, start, angle_deg, color=None): 69 | center = np.array(center) 70 | start = np.array(start) 71 | start_vector = start - center 72 | 73 | x, y = start_vector 74 | start_angle = np.rad2deg(np.arctan2(y, x)) 75 | 76 | radius = np.linalg.norm(start_vector) 77 | 78 | end_angle = start_angle + angle_deg 79 | 80 | self.msp.add_arc( 81 | center=center, 82 | radius=radius, 83 | start_angle=start_angle, 84 | end_angle=end_angle, 85 | is_counter_clockwise=(angle_deg > 0), 86 | dxfattribs={ 87 | "color": self._color(color) 88 | } 89 | ) 90 | 91 | def subdrawing(self, subdrawing_file, translate_xy, rotate_deg, is_no_ext=False): 92 | """Attention: not all elements are supported. 93 | Supported elements: LINE, CIRCLE, ARC, SPLINE.""" 94 | 95 | if is_no_ext: 96 | subdrawing_file += '.dxf' 97 | sub_dwg = DxfDrawing(file_path=subdrawing_file) 98 | 99 | fi = np.deg2rad(rotate_deg) 100 | rot_matrix = np.array([ 101 | [np.cos(fi), -np.sin(fi)], 102 | [np.sin(fi), np.cos(fi)], 103 | ]) 104 | 105 | def transform_point(p): 106 | p = (p[0], p[1]) 107 | p = np.matmul(rot_matrix, p) + translate_xy 108 | return p 109 | 110 | for e in sub_dwg.msp: 111 | dxf_type = e.dxftype() 112 | if dxf_type == 'LINE': 113 | p0 = transform_point(e.dxf.start) 114 | p1 = transform_point(e.dxf.end) 115 | self.line(p0, p1, color=e.dxf.color) 116 | elif dxf_type == 'CIRCLE': 117 | center = transform_point(e.dxf.center) 118 | diameter = e.dxf.radius * 2 119 | self.circle(center, diameter, color=e.dxf.color) 120 | elif dxf_type == 'ARC': 121 | center = transform_point(e.dxf.center) 122 | self.msp.add_arc( 123 | center=center, 124 | radius=e.dxf.radius, 125 | start_angle=e.dxf.start_angle + rotate_deg, 126 | end_angle=e.dxf.end_angle + rotate_deg, 127 | dxfattribs={'color': e.dxf.color} 128 | ) 129 | elif dxf_type == 'HATCH': 130 | points = e.paths.paths[0].vertices 131 | points_t = [] 132 | for point in points: 133 | points_t.append(transform_point(point)) 134 | self.polygon_filled(points_t, color=e.dxf.color) 135 | else: 136 | raise Exception(f'Unsupported element type: {dxf_type}') 137 | 138 | def deduplicate(self): 139 | class Line(NamedTuple): 140 | x0: float 141 | y0: float 142 | x1: float 143 | y1: float 144 | 145 | vlines = [] 146 | hlines = [] 147 | 148 | for e in self.msp: 149 | dxf_type = e.dxftype() 150 | if dxf_type != 'LINE': 151 | continue 152 | 153 | line = Line( 154 | x0=e.dxf.start[0], 155 | y0=e.dxf.start[1], 156 | x1=e.dxf.end[0], 157 | y1=e.dxf.end[1], 158 | ) 159 | if abs(line.x0 - line.x1) < self.EPSILON: 160 | vlines.append(line) 161 | e.destroy() 162 | if abs(line.y0 - line.y1) < self.EPSILON: 163 | hlines.append(line) 164 | e.destroy() 165 | self.msp.purge() 166 | 167 | def get_non_empty_groups_with_same_value(lines, idx): 168 | lines.sort(key=lambda line: line[idx]) 169 | batch = [] 170 | value = lines[0][idx] 171 | for line in lines: 172 | if abs(line[idx] - value) > self.EPSILON: 173 | if len(batch) > 0: # this case is redundant, but still 174 | yield batch 175 | batch = [] 176 | 177 | value = line[idx] 178 | batch.append(line) 179 | if len(batch) > 0: 180 | yield batch 181 | 182 | def draw_lines(batch, is_vlines): 183 | class Point(NamedTuple): 184 | value: float 185 | is_min: bool 186 | 187 | x0 = batch[0].x0 188 | y0 = batch[0].y0 189 | 190 | points = [] 191 | for line in batch: 192 | if is_vlines: 193 | p_min = min(line.y0, line.y1) 194 | p_max = max(line.y0, line.y1) 195 | else: 196 | p_min = min(line.x0, line.x1) 197 | p_max = max(line.x0, line.x1) 198 | points.append(Point(value=p_min, is_min=True)) 199 | points.append(Point(value=p_max, is_min=False)) 200 | points.sort(key=lambda point: point.value) 201 | 202 | counter = 0 203 | for i in range(len(points) - 1): 204 | point = points[i] 205 | point2 = points[i + 1] 206 | 207 | counter += 1 if point.is_min else -1 208 | 209 | if abs(point.value - point2.value) < self.EPSILON: 210 | continue 211 | 212 | if counter > 0: 213 | if is_vlines: 214 | p0 = (x0, point.value) 215 | p1 = (x0, point2.value) 216 | else: 217 | p0 = (point.value, y0) 218 | p1 = (point2.value, y0) 219 | self.line(p0, p1) 220 | 221 | x0_idx = 0 222 | for batch in get_non_empty_groups_with_same_value(vlines, x0_idx): 223 | draw_lines(batch, is_vlines=True) 224 | y0_idx = 1 225 | for batch in get_non_empty_groups_with_same_value(hlines, y0_idx): 226 | draw_lines(batch, is_vlines=False) 227 | 228 | def write(self, file, is_no_ext=False): 229 | if is_no_ext: 230 | file += '.dxf' 231 | else: 232 | _, ext = os.path.splitext(file) 233 | assert ext == '.dxf' 234 | 235 | self.dxf.saveas(file) 236 | 237 | def get_total_lines_length_mm(self, layout=None) -> float: 238 | """Attention: not all elements are supported. 239 | Supported elements: INSERT, LINE, CIRCLE, ARC, SPLINE.""" 240 | 241 | if layout is None: 242 | layout = self.msp 243 | 244 | total_length_mm = 0 245 | for e in layout: 246 | dxf_type = e.dxftype() 247 | 248 | if dxf_type == 'INSERT': 249 | block_name = e.dxf.name 250 | block = self.dxf.blocks[block_name] 251 | length = self.get_total_lines_length_mm(layout=block) 252 | elif dxf_type == 'LINE': 253 | length = ( 254 | (e.dxf.start[0] - e.dxf.end[0]) ** 2 + 255 | (e.dxf.start[1] - e.dxf.end[1]) ** 2 256 | ) ** 0.5 257 | elif dxf_type == 'CIRCLE': 258 | length = 2. * math.pi * e.dxf.radius 259 | elif dxf_type == 'ARC': 260 | total_angle = e.dxf.end_angle - e.dxf.start_angle 261 | assert total_angle >= 0, 'This should never happen...' 262 | length = np.deg2rad(total_angle) * e.dxf.radius 263 | elif dxf_type == 'HATCH': 264 | pass # we do not take polygons into account, because they are meant to be engraved, not cut 265 | # elif e.dxftype() == 'SPLINE': 266 | # points = e._control_points 267 | # length = 0 268 | # for i in range(len(points) - 1): 269 | # length += ( 270 | # (points[i][0] - points[i + 1][0]) ** 2 + 271 | # (points[i][1] - points[i + 1][1]) ** 2 272 | # ) ** 0.5 273 | else: 274 | raise Exception(f'Unsupported element type: {dxf_type}') 275 | 276 | total_length_mm += length 277 | 278 | return float(total_length_mm) 279 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](docs/images/logo.png) 2 | 3 | # Pine Craft: open-source generative constructor 4 | 5 | ## About 6 | 7 | Pine Craft is a constructor created for solving everyday tasks. Its key features are versatility, minimalism and simplicity of use. 8 | 9 | Its scope of application includes DIY projects, furniture making, home repairs, life hacks. In addition, it can be used to teach children construction design and robotics. Also, Pine Craft can be successfully used in startups and laboratories for making prototypes and fixtures. 10 | 11 | Pine Craft is very simple in manufacturing and usage. It requires a minimum of tools for assembly. The constructor is quickly assembled, and if necessary, it can be easily disassembled into parts for reuse. 12 | 13 | Pine Craft is suitable for use at home, as it is based on eco-friendly material - pine plywood. 14 | 15 | This repository contains a set of utilities for self-manufacturing Pine Craft parts, as well as detailed instructions for using this constructor. 16 | 17 | Watch this short video to learn how you can build with the Pine Craft: 18 | 19 | [![Pine Craft Promo](docs/images/youtube_teaser.png)](https://www.youtube.com/watch?v=A3GeZmPa8k8 "Pine Craft Promo") 20 | 21 | ## Examples 22 | 23 | You can find sample layout generations under `examples` folder. There are: 24 | 1. `simplest` - minimal viable example 25 | 2. `stool_kit` - a set of parts to assemble a stool 26 | 3. `universal_kit` - basic starter kit to manufacture if you want to play around with the Pine Craft 27 | 4. `advanced_features` - example-based tutorial on how to generate custom parts with `pine-craft` tool 28 | 5. `ready_layouts` - ready .dxf files for you to cut on laser. You can view them via [LibreCAD](https://www.librecad.org/) 29 | 30 | Examples of items made of Pine Craft can be found here: 31 | 32 | - [docs/examples.md](docs/examples.md) 33 | 34 | ![Pine Craft intro](docs/images/pine_craft_intro.jpg) 35 | 36 | ## Set of parts 37 | 38 | ### Beams and plates 39 | 40 | - The Pine Craft constructor consists of parts made of plywood on a laser cutter and screws to fasten them together. 41 | - Parts have a rectangular shape and their dimensions are multiples of the base segment size - `unit_size`. 42 | - Holes for connecting parts form a pattern that repeats with the `unit_size` step 43 | - One element of the pattern consists of 4 holes evenly distributed in the circle with the diameter of `unit_size/2`. 44 | - This arrangement of holes allows you to connect parts along and across 45 | - The `unit_size/2` diameter of the circle allows you to connect parts with a half step 46 | 47 | In practice, it is convenient to classify Pine Craft parts by their shape: 48 | 1. 1x1-sized parts are called spacers 49 | 2. linear parts with an aspect ratio of 1xN are called beams 50 | 1. recommended beam sizes are: 1x2, 1x3, 1x5, 1x7, 1x10, 1x14, 1x20 51 | 2. short beams - up to 1x5 - are used mainly for connecting parts 52 | 3. long beams - 1x10, 1x14, 1x20 - are used to create the frame of the structure 53 | 3. rectangular parts are called plates 54 | 1. recommended plate sizes are: 2x2, 2x3, 10x10, 10x15, 10x20, 10x30 55 | 2. small plates - 2x2, 2x3 - are needed to connect parts 56 | 3. large plates - 10x10 and larger - serve as working surfaces of products (shelf, seat, etc.) 57 | 58 | ### Cubes 59 | 60 | "Cubes" are used to connect parts in space. The cubes are assembled from two pairs of parts and are held together by the grooves and the tightening force of the screws. 61 | 62 | ![Pine Craft cube](docs/images/pine_craft_cube.jpg) 63 | 64 | ### Screws and nuts 65 | 66 | To connect Pine Craft parts, it is recommended to use M4 screws of two main lengths: 67 | 1. 20 mm - allows you to fasten 2 parts together 68 | 2. 50 mm - allows you to fasten together 7 parts, or one cube and two parts on both sides of it 69 | 70 | ![Screw lengths](docs/images/screw_lengths.jpg) 71 | 72 | I use hexagon head screws, as they wear out less when reused and have a lower chance of tearing them off when tightening. 73 | 74 | For M4 screws, a hex key with a side of 3mm is suitable. It is better to take a hex key with a convenient handle, since you will have to tinker a lot of screws :) And even better to buy a compact screwdriver! 75 | 76 | It is better to use nuts with a flange - in theory they should cling better to the plywood and prevent unscrewing. To support the nuts, you can use a 7 mm wrench, but in fact, you can do without it. 77 | 78 | Here is the bill of materials for screws and nuts: 79 | 1. DIN912 4x20 screw 80 | 2. DIN912 4x50 screw 81 | 3. Nut with flange DIN6923 m4 82 | 83 | ## Scale 84 | 85 | The standard dimensions of the constructor are adapted to the dimensions of structures 0.5-2 m and loads of 10-100 kg. These parameters are calculated for reasons of human use in everyday life. 86 | 87 | Here are the default dimensions: 88 | 1. The `unit_size` is 30x30 mm 89 | 2. Plywood thickness is 6 mm 90 | 3. Screws are M4 91 | 92 | However, all the dimensions of the constructor can be customized for your purpose (see `examples/advanced_features`). 93 | 94 | ## Manufacturing 95 | 96 | Pine Craft is recommended to be made of pine plywood, as it is a durable and eco-friendly material. It is not recommended to use plastic, acrylic, as well as chipboard materials. 97 | 98 | For manufacturing, it is recommended to use a laser cutter, not a milling cutter, since it is faster and utilizes the sheet completely because no technical clearances between parts are needed. 99 | 100 | You can use a built-in utility `pine-craft place-parts` to create a cutting layout in the .dxf format. This utility only supports laser fabrication, as it neglects the gaps between the parts. This is done intentionally, as it allows you to optimize the cutting length by an average of 25% since the contours of neighboring parts are cut simultaneously. The manufacturing time is also reduced by a quarter. With a laser cut width of about 1mm, this optimization does not harm the geometry of the constructor. 101 | 102 | Here is the example of the auto-generated cutting layout. It contains parts needed to make a [stool](docs/examples.md): 103 | 104 | ![Stool layout](docs/images/stool_layout.jpg) 105 | 106 | It is important for beams to have holes along the entire length, but this is not necessary for plates. A large number of holes greatly increases the cutting time - not only due to the length of the cut, but also due to the loss of time on moving the machine head between the holes. Therefore, it is recommended to use a sparse pattern for plates. 107 | 108 | In practice, only the holes on the edges of the plate are used. However, I also prefer to leave holes in the center for aesthetic purposes. 109 | 110 | Cutting layout has 3 colors: black, red and green. Black is used for contours of parts, red is for holes and green is for grooves. In .dxf these colors have numbers: black - 7, red - 1, green - 3. 111 | 112 | > In dxf color 7 is used for both black and white. That's why contours are white in the picture :) 113 | 114 | Grooves are needed in cube parts to give screws space. They should be 0.1-0.2 mm deep. To manufacture grooves, you should set the laser cut in engraving mode and let in engrave green polygons. You will need to find the appropriate power and speed to achieve the desired depth as it depends on the laser cutter. Try it on a small piece of plywood before cutting the main layout. 115 | 116 | It's important to cut everything in the right order: 117 | 1. cut holes and make grooves 118 | 2. cutout parts 119 | 120 | This order is needed because after the part is cut out, it can displace itself, so the holes cut after that will be misaligned. 121 | 122 | 123 | ## Install CLI 124 | 125 | Tested on Ubuntu 20.04. Should support Ubuntu/Debian out of the box, but for other Linux distributions may require tweaking. It's a Python3 program anyway, so it can be theoretically started on Mac/Windows, but you may need to change the code :) 126 | 127 | ### Linux 128 | 129 | #### Install globally 130 | 131 | 1. Put this directory to the place where all your programs live. Do not move it after installations or links will break! 132 | 2. Enter the directory `cd pine_craft` 133 | 3. Run script `./install.sh`. What it does: 134 | 1. installs packages `python3.9` and `python3.9-venv` 135 | 1. (if you want to use it with different version of python, you can edit `install.sh`. However, I tested it only with `python3.9`) 136 | 2. creates virtual environment `venv` in directory `pine_craft` 137 | 3. installs argcomplete globally - [like that](https://pypi.org/project/argcomplete/#activating-global-completion) 138 | 4. creates executable `/usr/bin/pine-craft` pointing to the `./pine-craft.py` 139 | 4. Restart your shell to make autocompletion work 140 | 5. Run `pine-craft --help` 141 | 142 | #### Uninstall 143 | 144 | 1. Remove python3.9 if you do not need it (which is unlikely) - `sudo apt-get uninstall python3.9 python3.9-venv` 145 | 2. Remove python auto-completion if you do not need it (but better do not do it, because other programs may use it) 146 | ``` 147 | # depends on where it is placed on your file system: 148 | rm /etc/bash_completion.d/python-argcomplete 149 | rm ~/.bash_completion.d/python-argcomplete 150 | ``` 151 | 3. Remove executable `sudo rm /usr/bin/pine-craft` 152 | 4. remove virtual env directory `cd pine_craft; sudo rm -r venv` 153 | 154 | #### Run without installation 155 | 156 | ```bash 157 | python3 ./pine-craft.py --help 158 | ``` 159 | 160 | or 161 | 162 | ```bash 163 | alias pine-craft="python3 ./pine-craft.py" 164 | pine-craft --help 165 | ``` 166 | 167 | You may need to install additional packages to your Python via `python3 -m pip install -r requirements.txt`. 168 | 169 | If you don't want to install them globally - create a virtual environment! 170 | 171 | ```bash 172 | python3 -m venv venv 173 | venv/bin/pip install --upgrade pip && venv/bin/pip install -r requirements.txt 174 | venv/bin/python3 ./pine-craft.py --help 175 | ``` 176 | 177 | > If you are not familiar with Python, virtual environment is just a regular directory, where all the libraries will be installed. So, to uninstall everything all you need is to delete the folder :) 178 | > 179 | > You can read about venv [here](https://docs.python.org/3/library/venv.html). 180 | 181 | ### Windows 182 | 183 | 1. If you don't have python: 184 | 1. Download python3 installer from [here](https://www.python.org/downloads/) 185 | 2. read the instruction [here](https://docs.python.org/3/using/windows.html) 186 | 3. open the command line (not a power shell!) 187 | 4. type `py --version` 188 | 5. if it responds with python version, that's it! 189 | 6. otherwise, re-read the instruction :) 190 | 2. open command line (not a power shell!) 191 | 3. navigate to the project directory `cd ` (you can copy path from file explorer) 192 | 4. make venv 193 | ```cmd 194 | py -m venv venv 195 | venv\Scripts\activate.bat 196 | pip install -r requirements.txt 197 | ``` 198 | 5. run: `py pine-craft.py --help` 199 | 6. you can create alias: 200 | ```cmd 201 | doskey pine-craft=py C:\path\to\pine-craft.py $* 202 | pine-craft --help 203 | ``` 204 | 7. `.sh` files won't run on Windows, but you can run `examples/simplest/simplest.bat` 205 | 206 | 207 | ## Use CLI 208 | 209 | Utility `pine-craft` has 4 sub-utilities: 210 | 1. `gen-box` - generate parts for `Cubes` 211 | 2. `gen-part` - generate regular parts 212 | 3. `place-parts` - generate optimal cutting layout from parts. Reads config from `.yaml` file 213 | 4. `cut-length` - compute total curves length in a `.dxf` file 214 | 215 | Each utility has its own help: `pine-craft gen-box --help` 216 | 217 | Examples of usage are placed under the `examples` folder. --------------------------------------------------------------------------------