├── .github └── workflows │ ├── docs.yml │ ├── python-conda.yml │ └── python-package.yml ├── .gitignore ├── LICENSE-agpl-3.0.txt ├── README.md ├── docs ├── Plycutter summary 2022-11-25.pdf ├── acknowledgments.md ├── api │ ├── plycutter.md │ └── plycutter │ │ ├── geometry │ │ ├── geom1d.md │ │ └── geom2d.md │ │ ├── sheetbuild.md │ │ └── sheetplex.md ├── biocta-1.png ├── biocta-cut-1.jpg ├── biocta-pattern.png ├── dev.md ├── gallery │ ├── dollhouse │ │ ├── index.md │ │ ├── model.jpeg │ │ ├── painted-1.jpeg │ │ ├── painted-2.jpeg │ │ ├── view1.jpeg │ │ ├── view2.jpeg │ │ └── view3.jpeg │ ├── format.md │ └── virtalahde-kotelo │ │ ├── index.md │ │ ├── virtalahde-kotelo-etu.jpg │ │ ├── virtalahde-kotelo-taka.jpg │ │ └── virtalahde-kotelo-v98.jpg ├── index.md ├── installation.md ├── plytest0.1.dxf.png └── plytest0.1.stl.png ├── environment.yml ├── mkdocs.yml ├── plycutter ├── __init__.py ├── canned.py ├── command_line.py ├── create_sheetplex.py ├── geometry │ ├── __init__.py │ ├── aabb.py │ ├── geom1d.py │ ├── geom2d.py │ ├── geom2dbase.py │ ├── impl2d │ │ ├── __init__.py │ │ ├── arrangement2d.py │ │ ├── cell_merger.py │ │ ├── frangle2d.py │ │ ├── geom2d_simple_holes.py │ │ ├── pmr_quadtree.py │ │ ├── primitive2d.py │ │ ├── scan2d.py │ │ ├── segment_tree.py │ │ └── tests │ │ │ ├── __init__.py │ │ │ ├── test_cell_merger.py │ │ │ ├── test_frangle2d.py │ │ │ ├── test_geom2d_simple_holes_cases.py │ │ │ ├── test_geom2d_simple_holes_stress.py │ │ │ ├── test_pmr_quadtree.py │ │ │ ├── test_primitive2d.py │ │ │ ├── test_scan2d.py │ │ │ ├── test_segment_tree.py │ │ │ └── util.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_aabb.py │ │ ├── test_benchmark.py │ │ ├── test_geom1d.py │ │ └── test_geom2d.py │ └── types2d.py ├── heuristics.py ├── misc.py ├── plytypes.py ├── sheetbuild.py ├── sheetplex.py ├── tests │ ├── __init__.py │ └── test_command_line.py └── writer.py ├── requirements.txt ├── setup.cfg ├── setup.py └── tests └── data └── PlyTest0.1.stl /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Documentation - Generate and deploy 3 | 4 | on: 5 | push: 6 | branches: [ master ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout base repo 13 | uses: actions/checkout@master 14 | 15 | - name: Set up Python 3.7 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: '3.7' 19 | 20 | - name: Install dependencies 21 | run: | 22 | # Install Shapely and gmpy2 deps 23 | sudo apt-get install libgeos-c1v5 libmpc-dev libspatialindex-dev 24 | python -m pip install --upgrade pip 25 | python -m pip install flake8 pytest 26 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 27 | 28 | - name: Deploy MkDocs 29 | run: | 30 | # Deploy 31 | PYTHONPATH=$PYTHONPATH:.. mkdocs gh-deploy --config-file ./mkdocs.yml -b ghpages --force 32 | -------------------------------------------------------------------------------- /.github/workflows/python-conda.yml: -------------------------------------------------------------------------------- 1 | name: Python tests in conda 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.9 18 | - name: Install dependencies 19 | run: | 20 | sudo $CONDA/bin/conda env update --file environment.yml --name base 21 | - name: Run smoketest 22 | run: | 23 | $CONDA/bin/pytest -k smoke 24 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python tests in pip 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: [ '3.7', # '3.8', '3.9' 19 | ] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | # Install Shapely and gmpy2 deps 30 | sudo apt-get install libgeos-c1v5 libmpc-dev libspatialindex-dev 31 | python -m pip install --upgrade pip 32 | python -m pip install flake8 pytest 33 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # we are strict about flake8 not warning on anything. Using noqa is allowed 39 | flake8 . --count --max-complexity=10 --statistics 40 | - name: Test with pytest 41 | run: | 42 | pytest -k smoke 43 | # XXX pytest 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vim 2 | *.swp 3 | *.swo 4 | 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plycutter - convert STL 3D models to finger-jointed 2D DXF laser cutter inputs 2 | 3 | For more information, see the [documentation](https://tjltjl.github.io/plycutter) 4 | 5 | Laser cut output, assembled 6 | CAD model 7 | CAD model 8 | -------------------------------------------------------------------------------- /docs/Plycutter summary 2022-11-25.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/Plycutter summary 2022-11-25.pdf -------------------------------------------------------------------------------- /docs/acknowledgments.md: -------------------------------------------------------------------------------- 1 | 2 | Thanks to Timo Jokitalo for pre-release feedback and suggesting the 3 | blog post. 4 | 5 | Thanks to Antti Hämäläinen for the courage to test plycutter with a 6 | large acrylic project. 7 | 8 | Thanks to Janne Kujala for discussions and the first test model not by 9 | the author. 10 | 11 | Thanks to my (tjltjl's) family for ideas, requirements and patience. 12 | -------------------------------------------------------------------------------- /docs/api/plycutter.md: -------------------------------------------------------------------------------- 1 | 2 | # API documentation 3 | -------------------------------------------------------------------------------- /docs/api/plycutter/geometry/geom1d.md: -------------------------------------------------------------------------------- 1 | # plycutter.geometry.geom1d 2 | 3 | ## plycutter.geometry.geom1d.Geom1D 4 | 5 | ::: plycutter.geometry.geom1d.Geom1D 6 | -------------------------------------------------------------------------------- /docs/api/plycutter/geometry/geom2d.md: -------------------------------------------------------------------------------- 1 | # plycutter.geometry.geom2d 2 | 3 | ## plycutter.geometry.geom2d.Geom2D 4 | 5 | ::: plycutter.geometry.geom2d.Geom2D 6 | selection: 7 | inherited_members: yes 8 | 9 | -------------------------------------------------------------------------------- /docs/api/plycutter/sheetbuild.md: -------------------------------------------------------------------------------- 1 | # plycutter.sheetbuild 2 | 3 | ## plycutter.sheetbuild.SheetBuild 4 | 5 | ::: plycutter.sheetbuild.SheetBuild 6 | selection: 7 | inherited_members: no 8 | 9 | --- 10 | 11 | ::: plycutter.sheetbuild.create_sheetbuild 12 | 13 | # 14 | -------------------------------------------------------------------------------- /docs/api/plycutter/sheetplex.md: -------------------------------------------------------------------------------- 1 | # plycutter.sheetplex 2 | 3 | --- 4 | 5 | # plycutter.sheetplex.SheetPlex 6 | 7 | ::: plycutter.sheetplex.SheetPlex 8 | 9 | --- 10 | 11 | # plycutter.sheetplex.Sheet 12 | 13 | ::: plycutter.sheetplex.Sheet 14 | 15 | --- 16 | 17 | # plycutter.sheetplex.Intersection 18 | 19 | ::: plycutter.sheetplex.Intersection 20 | 21 | --- 22 | 23 | # plycutter.sheetplex.InterSide 24 | 25 | ::: plycutter.sheetplex.InterSide 26 | -------------------------------------------------------------------------------- /docs/biocta-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/biocta-1.png -------------------------------------------------------------------------------- /docs/biocta-cut-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/biocta-cut-1.jpg -------------------------------------------------------------------------------- /docs/biocta-pattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/biocta-pattern.png -------------------------------------------------------------------------------- /docs/dev.md: -------------------------------------------------------------------------------- 1 | # Internals for developers 2 | 3 | ## Coding standard 4 | 5 | * PEP8 (autopep8). 6 | 7 | * flake8 must pass with no warnings. 8 | 9 | * Prefer immutable objects wherever appropriate. 10 | 11 | * Use exact rational numbers wherever appropriate. 12 | Do approximate operations only at points where it does not 13 | hurt the exactness of the results. 14 | The reason for this is that using exact 2D geometry 15 | gets rid of a lot of possible problem cases due to 16 | e.g. triangle flipping due to rounding errors. 17 | 18 | ## Plycutter process 19 | 20 | There are three different stages to plycutter: 21 | 22 | * Reading the .STL file into a `SheetPlex`. 23 | 24 | * Creating a "free" `SheetBuild` 25 | object and incrementally making heuristic choices 26 | within it. 27 | 28 | * Post-processing and writing out the .dxf files. 29 | 30 | ### SheetPlex 31 | 32 | A `SheetPlex` is a mostly policy-free representation 33 | of the input model, after finding where the possible 34 | sheets in the input model are. 35 | 36 | It contains slices through the input model to provide 37 | information for the heuristics as well as the relationships 38 | between the sheets. 39 | 40 | Intersections between sheets are represented, 41 | as well as the projection of the intersection to either of 42 | the sheets involved (this is important for the heuristics) 43 | 44 | ### SheetBuild 45 | 46 | A `SheetBuild` is the "blackboard" object used by the heuristic 47 | routines. It starts life as a very open description 48 | of the situation 49 | ("there could be material here on this sheet and here") 50 | and as the heuristics progress, they make decisions 51 | and convert some possible points to certainty 52 | and some to impossibility, thereby creating the joint 53 | between two sheets. 54 | 55 | A SheetBuild is a persistent map implemented 56 | using `pyrsistent`. This way, none of the functions *modify* 57 | anything but only return a new version. 58 | 59 | This makes debugging the heuristics a *lot* easier since 60 | it is easy to save the state at each point in the heuristics 61 | chain and rerun a particular step with changed code, 62 | with confidence that things are as they should be. 63 | 64 | ### Heuristics 65 | 66 | The heuristics start by looking at the proposed joints 67 | to see which parts are clearly not meant to be implemented 68 | by a particular sheet (just slicing the 3D model produces 69 | surprising things here that the heuristics mostly remove). 70 | 71 | After this, the heuristics look at multi-intersections, i.e., 72 | the intersections of more than two sheets since those regions 73 | require special care. 74 | 75 | Currently, one sheet is chosen for all of such an area 76 | (this is a feature where improvements are still needed). 77 | 78 | After this, the heuristics look at the two-sheet intersections 79 | and generate fingers there. 80 | 81 | The heuristics are in the package `plycutter.heuristics` 82 | and their driver is in `plycutter.canned`. 83 | 84 | ### Writeout 85 | 86 | Currently, the only postprocessing is the kerf compensation 87 | by a fixed amount. More could be added here, such as 88 | dog-bone compensation (though that might also belong 89 | in an earlier stage, this needs to be figured out when adding it) 90 | 91 | ## Libraries 92 | 93 | Plycutter includes some libraries to provide a good API 94 | for implementing the heuristics. 95 | 96 | ### Geometry 97 | 98 | The main library components are the `Geom1D` and 99 | `Geom2D` classes. 100 | They implement exact polygonal sets in 1D and 2D space, 101 | in a way that disallows zero-measure object droppings 102 | or cavities. 103 | This is the same as 'regularized boolean set-operations' in 104 | CGAL. 105 | 106 | #### Geom1D 107 | 108 | #### Geom2D 109 | 110 | The implementation of `Geom2D` 111 | is the largest part of plycutter currently; replacing 112 | this with a better, external implementation would be wonderful. 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /docs/gallery/dollhouse/index.md: -------------------------------------------------------------------------------- 1 | # Dollhouse 2 | 3 | 4 | 5 | Author: Tuomas Lukka (tjltjl) 6 | 7 | Material: 6mm plywood 8 | 9 | Tools: Fusion 360 10 | 11 | The project that launched plycutter. 12 | 13 | The original CAD model: 14 | 15 | CAD model 16 | 17 | Laser-cut and glued: 18 | 19 | 20 | 21 | 22 | 23 | Painted and with wallpaper: 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /docs/gallery/dollhouse/model.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/model.jpeg -------------------------------------------------------------------------------- /docs/gallery/dollhouse/painted-1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/painted-1.jpeg -------------------------------------------------------------------------------- /docs/gallery/dollhouse/painted-2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/painted-2.jpeg -------------------------------------------------------------------------------- /docs/gallery/dollhouse/view1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/view1.jpeg -------------------------------------------------------------------------------- /docs/gallery/dollhouse/view2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/view2.jpeg -------------------------------------------------------------------------------- /docs/gallery/dollhouse/view3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/dollhouse/view3.jpeg -------------------------------------------------------------------------------- /docs/gallery/format.md: -------------------------------------------------------------------------------- 1 | # Gallery format 2 | 3 | A single directory for each "project". 4 | 5 | A ``index.md`` file in the directory explaining what it is. (Link this 6 | into the main ``mkdocs.yml`` file in the gallery section). 7 | 8 | A header, explaining what it is and what it is made of. 9 | Use an ```` tag with ``align="right"`` to show 10 | 11 | Some pictures. Use ```` tags. 12 | 13 | An ``.stl`` file is optional. 14 | 15 | Other source files (e.g. OpenSCAD, Fusion 360, ...) are optional. 16 | 17 | TODO: STL visualizaton; figure out an automatic build of all projects 18 | with latest plycutter plus visualization of the resulting sheet model. 19 | 20 | -------------------------------------------------------------------------------- /docs/gallery/virtalahde-kotelo/index.md: -------------------------------------------------------------------------------- 1 | # Box for DPS5020 Digital Power Supply 2 | 3 | 4 | 5 | 6 | Author: Jukka Järvenpää/Helsinki Hacklab 7 | 8 | Material: 3mm acrylic 9 | 10 | Tools: Fusion 360 11 | 12 | The original CAD model: 13 | 14 | CAD model 15 | 16 | -------------------------------------------------------------------------------- /docs/gallery/virtalahde-kotelo/virtalahde-kotelo-etu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/virtalahde-kotelo/virtalahde-kotelo-etu.jpg -------------------------------------------------------------------------------- /docs/gallery/virtalahde-kotelo/virtalahde-kotelo-taka.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/virtalahde-kotelo/virtalahde-kotelo-taka.jpg -------------------------------------------------------------------------------- /docs/gallery/virtalahde-kotelo/virtalahde-kotelo-v98.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/gallery/virtalahde-kotelo/virtalahde-kotelo-v98.jpg -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Plycutter - STL to finger-jointed DXF slicer 2 | 3 | Author: Tuomas Lukka, tuomas@hipcode.fi 4 | 5 | [Official homepage](https://tjltjl.github.io/plycutter) 6 | 7 | [Github repo](https://github.com/tjltjl/plycutter) 8 | 9 | [Introductory blog post with lots of pictures](https://hipcode.fi/?p=25324) 10 | 11 | ## Introduction 12 | 13 | CAD model 14 | 15 | Plycutter is a program that takes a 3D model in an `.stl` file and 16 | generates 2D `.dxf` files suitable for laser cutting, with generated 17 | finger joints between material sheets where appropriate. 18 | 19 | The 3D needs to 20 | be designed in a way where the sheets are clearly visible 21 | (e.g., using the CAD program's 'shell' command). 22 | 23 | Example (images on the right): pen holder consisting of two stacked octahedra 24 | (scale model from 3mm plywood). 25 | 26 | * Top: CAD model. The CAD model was designed as two 27 | octahedra on top of each other and the 'shell' command 28 | in the CAD program (Fusion 360) was used to produce 29 | the shape consisting of sheets. A further cut on the top 30 | was made to create the curved edges. 31 | This model was then exported as a `.stl` 32 | 33 | CAD model 34 | Laser cut output, assembled 35 | 36 | * Middle: 37 | Result of running plycutter on the `.stl` file 38 | to produce `.dxf` file for laser cutter. 39 | 40 | * Bottom: the laser-cut plywood parts assembled. 41 | In cases where the vertical cuts from the laser cutter 42 | cannod exactly follow the model (such as the tips of the top 43 | triangle or the non-90-degree joints), 44 | plycutter tries to retain as much material as possible 45 | so that a quick sanding or filing operation will get 46 | the desired shape. 47 | 48 | For non-90-degree joints, some gaps are unavoidable but 49 | a multitools and wood filler make quick work of those. 50 | 51 | 52 | For more showcase images, see [Plycutter summary PDF](./Plycutter summary 2022-11-25.pdf) 53 | 54 | **Note** Plycutter is alpha-stage software and probably contains 55 | many bugs. Please report any bugs as issues in the github project, 56 | preferably with a pull request with a failing test, 57 | or at least a minimal .stl file and command line for reproducing 58 | the problem. 59 | 60 | ## Purpose / use cases 61 | 62 | Plycutter is at its best for making one-off prototypes 63 | or small production runs, or for generative objects 64 | where each object is custom-generated from user input. 65 | 66 | Unlike with 3D printers, the workspace is much larger 67 | (depending on the laser cutter used; with a little planning, 68 | it is also possible to make much larger objects than your 69 | laser cutter - only each sheet must fit). 70 | 71 | The author has personally used it to iterate on some household 72 | object concepts, where it provides a fast and robust way 73 | (compared to 3D printing, for example) of iterating 74 | large objects. 75 | 76 | Currently, the program generates the fingers somewhat randomly 77 | which makes it next to impossible to assemble the objects 78 | wrong and also makes them into interesting puzzles. 79 | 80 | ## Installing plycutter 81 | 82 | ### Installing dependencies 83 | 84 | On a recent ubuntu, you can do the following. 85 | 86 | sudo apt-get install libgeos-c1v5 libmpc-dev libspatialindex-dev 87 | python -m pip install --upgrade pip 88 | python -m pip install flake8 pytest 89 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 90 | 91 | This is copied from the github test action so it should work 92 | ok. 93 | 94 | On MacOS, the author uses conda to install the dependencies, see ``environment.yml``. 95 | 96 | ### Installing plycutter itself 97 | 98 | #### Conventional method using ``setup.py`` 99 | 100 | To install plycutter into your current python environment, run the command 101 | 102 | python setup.py install 103 | 104 | After this, running ``plycutter`` as a shell command should work 105 | 106 | #### Using ``pipx`` 107 | 108 | It is also possible to install plycutter and its dependencies via [pipx](https://github.com/pipxproject/pipx). 109 | 110 | pipx install git+https://github.com/tjltjl/plycutter.git 111 | 112 | See the ``pipx`` dodumentation for details 113 | 114 | ## Basic usage; Getting started 115 | 116 | 117 | Install plycutter. Run the tests (pytest) to ensure all dependencies work. 118 | 119 | Next, run the input file `PlyTest0.1.stl` which contains a very 120 | simple object consisting of two sheets that meet at a corner. 121 | 122 | STL contents 123 | 124 | We run plycutter from the command line using the following command: 125 | 126 | plycutter -o foo.dxf --thickness 6 ./tests/data/PlyTest0.1.stl 127 | 128 | which produces the output `foo.dxf`. You can open it in, e.g., Inkscape. 129 | The following image shows the contents of such a file; 130 | the lines on the left and on the bottom are Inkscape's paper edge. 131 | 132 | STL contents 133 | 134 | Now it is possible to run this file in a laser cutter to obtain 135 | two pieces of e.g. plywood or acrylic that 136 | fit each other perfectly. 137 | 138 | 139 | ## `plycutter` Command line options 140 | 141 | usage: plycutter [-h] [--thickness THICKNESS] 142 | [--min_finger_width MIN_FINGER_WIDTH] 143 | [--max_finger_width MAX_FINGER_WIDTH] 144 | [--support_radius SUPPORT_RADIUS] [--debug] 145 | [--final_dilation FINAL_DILATION] [--random_seed RANDOM_SEED] 146 | [--only_sheets ONLY_SHEETS] [-o OUTPUT_FILE] 147 | infile 148 | 149 | positional arguments: 150 | infile STL file to process 151 | 152 | optional arguments: 153 | -h, --help show this help message and exit 154 | --thickness THICKNESS 155 | Set the thickness of sheets to find. (default: 6) 156 | --min_finger_width MIN_FINGER_WIDTH 157 | Set minimum width for generated fingers. (default: 3) 158 | --max_finger_width MAX_FINGER_WIDTH 159 | Set maximum width for generated fingers. (default: 5) 160 | --support_radius SUPPORT_RADIUS 161 | Set maximum range for generating material on a sheet 162 | where neither surface is visible (default: 12) 163 | --debug Turn on debugging. (default: False) 164 | --final_dilation FINAL_DILATION 165 | Final dilation (laser cutter kerf compensation) 166 | (default: 1/20) 167 | --random_seed RANDOM_SEED 168 | Random seed for pseudo-random heuristics (default: 42) 169 | --only_sheets ONLY_SHEETS 170 | Not implemented yet (default: None) 171 | -o OUTPUT_FILE, --output_file OUTPUT_FILE 172 | File to write the DXF output in (default: None) 173 | 174 | ## The most relevant known limitations 175 | 176 | Plycutter is a hobby project, published in hope that it will be 177 | useful. Currently, the author (Tuomas Lukka) is in freelancer mode 178 | so feel free to contact the author to offer a consulting gig if 179 | there is a particular limitation you would like to see get worked 180 | on ASAP (or for other projects :) ). 181 | 182 | * For large models and models with many curves, plycutter is 183 | currently relatively slow. 184 | Even for small models, getting some speedup would be 185 | very welcome. 186 | The slowness is due to the exact 2D library that was written 187 | as a quick replacement to a commonly used 188 | off-the-shelf Python 2D library when it turned 189 | out that rounding errors from floats 190 | made it impossible to use 191 | in this work (the joint fingers are generated along linees 192 | and getting rounding errors that flip vertices' 193 | area were causing assertion failures). 194 | There are several badly scaling algorithms in plycutter's 195 | own 2D geometry library and those 196 | algorithms need to be replaced with faster 197 | ones. 198 | The ideal solutioin would be to replace the 2D library 199 | wth an external one that 200 | can do exact, rational geometry fast, but so far, 201 | I have not had success with this. 202 | The `Geom2D` API has been kept simple for this reason. 203 | 204 | * Shallow joints (between 135 and 180 degrees) 205 | are currently handled badly, with finger lengths becoming 206 | extreme. 207 | The system should understand when it does not make sense 208 | to make the fingers longer. 209 | 210 | * Long joints where more than 2 sheets meet are handled very 211 | rudimentarily and can produce unexpected results. 212 | Writing an algorithm that does better is fairly 213 | straightforward but hasn't been done yet. 214 | The function that makes the simplistic decisions is 215 | `heuristic_multi_inter_single_decisions` 216 | in `plycutter/heuristics.py`. 217 | 218 | * For two sheets that cross each other in an "X" shape, 219 | plycutter will currently produce output that would only 220 | be assemblable in 4D. I.e. it will produce holes on both 221 | sheets that would fit together if it were possible 222 | to assemble the sheets. The real solution here is to allow 223 | plycutter to cut one of the sheets into parts but doing 224 | that correctly requires... 225 | 226 | * ...buildability analysis. It is possible to make 2D patterns 227 | that cannot be assembled. For example, the two interlocking 228 | cubes example could have produced such a pattern but luckily 229 | did not. 230 | 231 | * Curved sheets are not yet supported (curved sheet **edges** 232 | work fine; of course they get subdivided into 233 | lines in the STL export). 234 | The architecture is should be fairly easy 235 | to extend in that direction: 236 | the `Sheet`, `Inter` and `InterSide` objects are designed 237 | in a way that may make this easy. 238 | Representing the `InterSide` as a subdivided polygonal 239 | curve is probably the easiest approach to integrate 240 | with the current `Geom2D` code. 241 | Naturally, the curved sheets should only be [developable 242 | surfaces(Wikipedia)](https://en.wikipedia.org/wiki/Developable_surface) 243 | 244 | * Currently, plycutter is not able to make use of the capabilities 245 | of 5-axis laser or water cutters or mills, 246 | mostly because the author has no access to such machines. 247 | If you are able to arrange such access, please get in touch. 248 | The biggest plus of 5 axes is that in non-90-degree joints, 249 | there will be no gaps or protruding parts. 250 | However, it gets better: 5 axes will enable a wide 251 | variety of joint shapes. 252 | 253 | * Milling or water cutting may require dog bone corners which 254 | are also not implemented for the above reason. 255 | 256 | * The joint pieces that belong together are not marked in any way 257 | currently. Adding a laser-carved number would be a great way 258 | to help the assembly process when there are many parts 259 | (for example, the dollhouse stairs were quite an interesting 260 | task to assemble...) 261 | 262 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installing plycutter 2 | 3 | ## Basic installation 4 | 5 | ### Linux 6 | 7 | On a recent ubuntu, you can do the following. 8 | 9 | sudo apt-get install libgeos-c1v5 libmpc-dev libspatialindex-dev 10 | python -m pip install --upgrade pip 11 | python -m pip install flake8 pytest 12 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 13 | 14 | This is copied from the github test action so it should work 15 | ok. 16 | 17 | ### Conda 18 | 19 | On MacOS (and probably other OSes), you can use conda to install the dependencies using 20 | 21 | conda env update --file environment.yml 22 | 23 | in the main plycutter directory. If you like, you can create a new conda environment 24 | for plycutter before doing this. 25 | 26 | ## After installation 27 | 28 | You can run the tests using ``pytest``. 29 | -------------------------------------------------------------------------------- /docs/plytest0.1.dxf.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/plytest0.1.dxf.png -------------------------------------------------------------------------------- /docs/plytest0.1.stl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/docs/plytest0.1.stl.png -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: plycutter 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - numpy 6 | - pyrsistent 7 | - ezdxf 8 | - gmpy2 9 | - trimesh 10 | - shapely 11 | - hypothesis 12 | - matchpy 13 | - pytest-benchmark 14 | - sortedcontainers 15 | - matplotlib 16 | - scipy 17 | - networkx 18 | - rtree 19 | - mkdocs 20 | - mkdocs-material 21 | - mkdocs-material-extensions 22 | - mkdocstrings 23 | - openblas 24 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: PlyCutter 2 | plugins: 3 | - search 4 | - mkdocstrings: 5 | handlers: 6 | python: 7 | selection: 8 | inherited_members: no 9 | rendering: 10 | show_category_heading: yes 11 | show_source: no 12 | 13 | theme: 14 | name: 15 | material 16 | nav: 17 | - 'index.md' 18 | - 'Gallery': 19 | - 'gallery/format.md' 20 | - 'Dollhouse': 'gallery/dollhouse/index.md' 21 | - 'acknowledgments.md' 22 | - 'dev.md' 23 | - 'api/plycutter.md' 24 | - 'api/plycutter/sheetplex.md' 25 | - 'api/plycutter/sheetbuild.md' 26 | - 'api/plycutter/heuristics.md' 27 | - 'api/plycutter/geometry/geom1d.md' 28 | - 'api/plycutter/geometry/geom2d.md' 29 | -------------------------------------------------------------------------------- /plycutter/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/canned.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """ 20 | Canned routines to run a bunch of heuristic steps for generating finger joints. 21 | """ 22 | 23 | import logging 24 | import argparse 25 | 26 | from . import sheetbuild 27 | from . import heuristics as heu 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | sb_log = None 32 | """A global variable where the log from canned_1 is stored for debugging. 33 | 34 | Use this variable in jupyterlab to poke around. 35 | """ 36 | 37 | 38 | def canned_1(sheetplex, params, show=False): 39 | """Run a number of heuristic steps to makek a finger jointed 2d pattern""" 40 | 41 | global sb_log 42 | sb_log = [] 43 | ok = False 44 | try: 45 | sp = sheetplex 46 | logger.info("Create sheetbuild") 47 | sb = sheetbuild.create_sheetbuild(sp, params) 48 | sb_log.append(sb.set('dbg', 'create')) 49 | 50 | sb.check() 51 | 52 | if show: 53 | do_show('Creation', sb) 54 | 55 | logger.info("Process") 56 | for op in [ 57 | heu.choose_obviously_irrelevant, 58 | heu.remove_loose, 59 | heu.update_intersection_ok, 60 | heu.update_sheet_ok, 61 | heu.update_intersection_ok, 62 | heu.remove_loose, 63 | heu.update_intersection_ok, 64 | heu.heuristic_multi_inter_single_decisions, 65 | heu.update_intersection_ok, 66 | heu.choose_the_ok_side, 67 | heu.single_fingers_random, 68 | heu.choose_all_ok, 69 | heu.clean_up_thin_ok_parts, 70 | # select_all, 71 | ]: 72 | if op is None: 73 | break 74 | logger.info(op) 75 | sb_log.append(sb.set('dbg', op)) 76 | sb = op(sb, params) 77 | if show: 78 | do_show(op, sb) 79 | 80 | sb_log.append(sb) 81 | sb.check(force=True) 82 | 83 | ok = True 84 | 85 | except Exception as e: 86 | logger.error('Exception in canned_1: %s', e, exc_info=True) 87 | if not params['return_on_failure']: 88 | raise 89 | 90 | return argparse.Namespace( 91 | ok=ok, 92 | sheetbuild=sb, 93 | history=sb_log, 94 | sheet_cuts=sb.sheet_chosen, 95 | params=params, 96 | ) 97 | 98 | 99 | def do_show(stage, sb): 100 | from IPython.display import display 101 | from pylab import subplots 102 | sp = sb.sheetplex 103 | s = list(sorted(sp.sheets.keys())) 104 | N = 4 105 | print('AFTER STAGE', stage) 106 | i = 0 107 | fig = None 108 | for sheet_id in s: 109 | if i % N == 0: 110 | if fig is not None: 111 | display(fig) 112 | fig, ax = subplots(1, N, figsize=(15, 4)) 113 | ax[i % N].set_title(sheet_id) 114 | sheetbuild.show_sheet(ax[i % N], sb.sheetplex, sb, sheet_id) 115 | i += 1 116 | if fig is not None: 117 | display(fig) 118 | -------------------------------------------------------------------------------- /plycutter/command_line.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import argparse 20 | import logging 21 | import pathlib 22 | import sys 23 | 24 | import trimesh 25 | 26 | from . import create_sheetplex 27 | from . import canned 28 | from . import writer 29 | from .plytypes import F 30 | 31 | logging.basicConfig(level=logging.DEBUG) 32 | logger = logging.getLogger(__name__) 33 | 34 | logging.getLogger("trimesh").setLevel(logging.INFO) 35 | logging.getLogger("ezdxf").setLevel(logging.WARN) 36 | logging.getLogger("chardet.charsetprober").setLevel(logging.WARN) 37 | logging.getLogger("matplotlib").setLevel(logging.WARN) 38 | 39 | 40 | def main(arguments=sys.argv[1:]): 41 | parser = argparse.ArgumentParser( 42 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 43 | ) 44 | parser.add_argument( 45 | "--thickness", 46 | type=F, 47 | default=6, 48 | help="Set the thickness of sheets to find.", 49 | ) 50 | parser.add_argument( 51 | "--min_finger_width", 52 | type=F, 53 | default=3, 54 | help="Set minimum width for generated fingers.", 55 | ) 56 | parser.add_argument( 57 | "--max_finger_width", 58 | type=F, 59 | default=5, 60 | help="Set maximum width for generated fingers.", 61 | ) 62 | parser.add_argument( 63 | "--support_radius", 64 | type=F, 65 | default=12, 66 | help="Set maximum range for generating material " 67 | "on a sheet where " 68 | "neither surface is visible", 69 | ) 70 | parser.add_argument( 71 | "--debug", action="store_true", help="Turn on debugging." 72 | ) 73 | 74 | parser.add_argument( 75 | "--vdebug", 76 | action="store_true", 77 | help="Turn on visual debugging (try in jupyterlab).", 78 | ) 79 | 80 | parser.add_argument( 81 | "--final_dilation", 82 | type=F, 83 | default=F(5, 100), 84 | help="Final dilation (laser cutter kerf compensation)", 85 | ) 86 | 87 | parser.add_argument( 88 | "--random_seed", 89 | type=int, 90 | default=42, 91 | help="Random seed for pseudo-random heuristics", 92 | ) 93 | 94 | # XXX Doesn't convert properly yet 95 | parser.add_argument( 96 | "--only_sheets", type=str, default=None, help="Not implemented yet" 97 | ) 98 | 99 | parser.add_argument( 100 | "-o", 101 | "--output_file", 102 | type=str, 103 | default=None, 104 | help="File to write the output in", 105 | ) 106 | 107 | parser.add_argument( 108 | "-f", "--format", type=str, default="dxf", help="dxf or svg output" 109 | ) 110 | 111 | parser.add_argument("infile", type=str, help="STL file to process") 112 | 113 | args = parser.parse_args(arguments) 114 | 115 | infile = pathlib.Path(args.infile) 116 | 117 | args.format = args.format.lower() 118 | if args.format not in ("svg", "dxf"): 119 | raise Exception("Improper file format") 120 | 121 | if args.output_file is None: 122 | outfile = infile.with_suffix("." + args.format) 123 | else: 124 | outfile = args.output_file 125 | 126 | logger.info(f'Loading "{infile}. Will write "{outfile}""') 127 | mesh = trimesh.load(str(infile)) 128 | logger.info(f"Parameters: {args}") 129 | 130 | logger.info("Creating sheetplex") 131 | sp = create_sheetplex.create_sheetplex(mesh, vars(args)) 132 | if len(sp.sheets) == 0: 133 | raise Exception("No sheets found") 134 | 135 | logger.info("Running canned heuristic steps") 136 | 137 | result = canned.canned_1(sp, vars(args), args.vdebug) 138 | 139 | logger.info(f'Writing "{outfile}"') 140 | to_write = result.sheet_cuts 141 | 142 | to_write_dilated = { 143 | k: v.buffer(args.final_dilation) for k, v in to_write.items() 144 | } 145 | print(to_write_dilated) 146 | 147 | if args.format == "dxf": 148 | writer.write_dxf(str(outfile), to_write_dilated) 149 | elif args.format == "svg": 150 | writer.write_svg(str(outfile), to_write_dilated) 151 | 152 | logger.info("Done!") 153 | -------------------------------------------------------------------------------- /plycutter/create_sheetplex.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """Create a sheetplex from a 3D model 20 | """ 21 | 22 | import argparse 23 | import shapely 24 | import shapely.ops 25 | import functools 26 | import operator 27 | import pyrsistent as pyr 28 | import numpy as np 29 | import logging 30 | import warnings 31 | 32 | from .sheetplex import SheetPlex, Sheet, Intersection, InterSide 33 | from .plytypes import Fraction 34 | from .geometry.geom2d import Geom2D 35 | 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | def create_sheetplex(tmesh, params): 40 | """Create a SheetPlex out of a trimesh mesh. 41 | """ 42 | # Only stage where we use floats... 43 | thickness = float(params["thickness"]) 44 | 45 | # Turn off Visibledeprecationwarning 46 | # from trimesh <-> numpy interaction 47 | with warnings.catch_warnings(): 48 | warnings.filterwarnings( 49 | "ignore", category=np.VisibleDeprecationWarning 50 | ) 51 | 52 | sheets = create_sheets( 53 | tmesh, 54 | thickness, 55 | offset_tolerance=0.1, 56 | normal_tolerance=0.001, 57 | only=params["only_sheets"], 58 | ) 59 | 60 | logger.info(f"Found {len(sheets)} sheets") 61 | 62 | sp = SheetPlex(sheets=pyr.m(**sheets), intersections=pyr.m()) 63 | 64 | sp = create_intersections(sp) 65 | 66 | return sp 67 | 68 | 69 | def create_sheets( 70 | mesh, # noqa XXX Too complex function 71 | thickness, 72 | offset_tolerance, 73 | normal_tolerance, 74 | only=None, 75 | ): 76 | """ 77 | Find the sheets that the mesh consists of. 78 | 79 | Heuristically finds the laser-cut sheets needed to construct 80 | the given mesh. 81 | 82 | Returns 83 | dict of sheet id -> Sheet object 84 | """ 85 | if only is not None: 86 | only = set(only) 87 | 88 | sheets = {} 89 | 90 | idx = 0 91 | all_facets = find_all_facets(mesh) 92 | todo_facets = {i for i in np.arange(len(all_facets.facets))} 93 | facet_order = reversed(np.argsort(all_facets.areas)) 94 | 95 | n_slices = 3 96 | 97 | for facet in facet_order: 98 | if facet in todo_facets: 99 | todo_facets.remove(facet) 100 | 101 | normal, offset = _facet_plane(all_facets, facet) 102 | logger.debug(f"Facet {facet}: {normal} {offset}") 103 | 104 | oppnormal, oppoffset = -normal, -(offset - thickness) 105 | sides = np.array( 106 | [list(normal) + [offset], list(oppnormal) + [oppoffset],], 107 | np.float64, 108 | ) 109 | 110 | out_tolerance = offset_tolerance 111 | slices_height = [ 112 | offset + out_tolerance, 113 | offset - thickness - out_tolerance, 114 | ] + list( 115 | np.linspace( 116 | offset - thickness + offset_tolerance, 117 | offset - offset_tolerance, 118 | n_slices, 119 | ) 120 | ) 121 | 122 | slices = mesh.section_multiplane([0, 0, 0], normal, slices_height) 123 | outside_slices = [ 124 | Geom2D.from_shapely(shapely.ops.unary_union(sl.polygons_full)) 125 | if sl 126 | else Geom2D.empty() 127 | # sl if sl else Geom2D.empty() 128 | for sl in slices[0:2] 129 | ] 130 | slices = slices[2:] 131 | 132 | for sl in slices: 133 | assert sl is not None, ( 134 | "Null slice - wrong thickness setting?", 135 | normal, 136 | offset, 137 | ) 138 | 139 | slice_rotation = slices[0].metadata["to_3D"].copy() 140 | # Remove translation 141 | slice_rotation[0:3, 3] = 0 142 | 143 | slices = [ 144 | Geom2D.from_shapely(shapely.ops.unary_union(sl.polygons_full)) 145 | for sl in slices 146 | ] 147 | 148 | same = [facet] 149 | opposed = [] 150 | within = [] 151 | 152 | for other_facet in list(todo_facets): 153 | assert other_facet != facet 154 | _find_facet_status( 155 | other_facet, 156 | same, 157 | opposed, 158 | within, 159 | mesh, 160 | all_facets, 161 | todo_facets, 162 | normal, 163 | offset, 164 | thickness, 165 | normal_tolerance, 166 | offset_tolerance, 167 | ) 168 | if len(opposed) == 0: 169 | logger.debug("Skipping one at %s, no opposed facets", idx) 170 | continue 171 | 172 | id = "s%d" % idx 173 | idx += 1 174 | 175 | logger.debug(f"Adding sheet {id}") 176 | 177 | # Find area supported by actual faces 178 | 179 | trb = slice_rotation.T 180 | faces = [] 181 | for fs in (same, opposed): 182 | fsupport = Geom2D.empty() 183 | for f in fs: 184 | for tri in all_facets.facets[f]: 185 | verts = ( 186 | trb[:3, :3].dot(mesh.triangles[tri].T).T[:, 0:2] 187 | ) 188 | verts = vFraction(verts) 189 | fsupport = fsupport | Geom2D.polygon( 190 | verts, reorient=True 191 | ) 192 | faces.append(fsupport) 193 | 194 | slices_max = functools.reduce(operator.or_, slices) 195 | slices_min = functools.reduce(operator.and_, slices) 196 | 197 | if only is not None and id not in only: 198 | logger.debug('Skipping due to "only" setting') 199 | continue 200 | 201 | sheets[id] = Sheet( 202 | id=id, 203 | sides=sides, 204 | slice_rotation=slice_rotation, 205 | inverse_slice_rotation=slice_rotation.T, 206 | faces=faces, 207 | outside_slices=outside_slices, 208 | both_faces=faces[0] & faces[1], 209 | slices_max=slices_max, 210 | slices_min=slices_min, 211 | ) 212 | 213 | return sheets 214 | 215 | 216 | def _find_facet_status( 217 | other_facet, 218 | same, 219 | opposed, 220 | within, 221 | mesh, 222 | all_facets, 223 | todo_facets, 224 | normal, 225 | offset, 226 | thickness, 227 | normal_tolerance, 228 | offset_tolerance, 229 | ): 230 | other_normal, other_offset = _facet_plane(all_facets, other_facet) 231 | 232 | norm = np.linalg.norm 233 | 234 | # Same plane? 235 | if ( 236 | norm(other_normal - normal) < normal_tolerance 237 | and norm(other_offset - offset) < offset_tolerance 238 | ): 239 | same.append(other_facet) 240 | todo_facets.remove(other_facet) 241 | return 242 | 243 | # Opposing side of plane 244 | if ( 245 | norm(-other_normal - normal) < normal_tolerance 246 | and norm(-other_offset - offset + thickness) < offset_tolerance 247 | ): 248 | opposed.append(other_facet) 249 | todo_facets.remove(other_facet) 250 | return 251 | 252 | # Within plane 253 | if abs(np.dot(other_normal, normal)) < normal_tolerance: 254 | # Check that all points are within this and other 255 | boundary_verts = set() 256 | for v0, v1 in all_facets.boundaries[other_facet]: 257 | boundary_verts.add(v0) 258 | boundary_verts.add(v1) 259 | is_within = True 260 | for v in boundary_verts: 261 | if not ( 262 | offset - thickness - offset_tolerance 263 | <= np.dot(mesh.vertices[v], normal) 264 | <= offset + offset_tolerance 265 | ): 266 | is_within = False 267 | break 268 | if is_within: 269 | within.append(other_facet) 270 | todo_facets.remove(other_facet) 271 | return 272 | 273 | 274 | vFraction = np.vectorize(Fraction, [object]) 275 | """Vectorize a numpy array""" 276 | 277 | 278 | def _facet_plane(all_facets, facet): 279 | """Get the plane dot vector and offset for a facet. 280 | """ 281 | origin = all_facets.origins[facet] 282 | normal = all_facets.normals[facet] 283 | return normal, np.dot(origin, normal) 284 | 285 | 286 | # 287 | 288 | 289 | def find_all_facets(mesh): 290 | """Return all facets of a mesh. 291 | 292 | Work around a trimesh API pain point where only 293 | sides with 2 or more triangles count as facets. 294 | 295 | Returns: a Namespace with the following parallel facet lists 296 | facets - list of lists of triangle ids 297 | normals - list of normal vectors 298 | origins - list of a single point on each facet 299 | areas - list of total area of the facet 300 | boundaries - list of facet boundary vertices 301 | """ 302 | assert len(mesh.faces) == len(mesh.triangles) 303 | triangles_left = set(range(len(mesh.triangles))) 304 | 305 | facets = list(mesh.facets) 306 | normals = list(mesh.facets_normal) 307 | 308 | if mesh.facets_origin is not None: 309 | # For some reason, this gets None and not [] 310 | origins = list(mesh.facets_origin) 311 | else: 312 | origins = [] 313 | 314 | areas = list(mesh.facets_area) 315 | boundaries = list(mesh.facets_boundary) 316 | for facet in facets: 317 | triangles_left -= set(facet) 318 | 319 | for triangle_id in triangles_left: 320 | facets.append([triangle_id]) 321 | normals.append(mesh.face_normals[triangle_id]) 322 | areas.append(mesh.area_faces[triangle_id]) 323 | origins.append(mesh.triangles[triangle_id][0]) 324 | b = [] 325 | for i in range(3): 326 | b.append( 327 | ( 328 | mesh.faces[triangle_id][i], 329 | mesh.faces[triangle_id][(i + 1) % 3], 330 | ) 331 | ) 332 | boundaries.append(b) 333 | 334 | return argparse.Namespace( 335 | facets=facets, 336 | normals=normals, 337 | origins=origins, 338 | areas=areas, 339 | boundaries=boundaries, 340 | ) 341 | 342 | 343 | def create_intersections(sp): 344 | sheets = list(sp.sheets.values()) 345 | for i, sheet0 in enumerate(sheets): 346 | for sheet1 in sheets[i + 1 :]: 347 | sp = create_intersection(sp, (sheet0, sheet1)) 348 | return sp 349 | 350 | 351 | def create_intersection(sp, sheets): 352 | id = intersection_id(sheets) 353 | 354 | sheet1, sheet2 = sheets 355 | 356 | # Intersection line direction in 3-space 357 | inter_dir = np.cross(sheet1.normal(0), sheet2.normal(0)) 358 | ilen = np.linalg.norm(inter_dir) 359 | 360 | if ilen < 0.0001: # Parallel -- no intersection 361 | return sp 362 | 363 | inter_dir /= ilen 364 | 365 | # Intersection origin in 3-space must be on both planes. 366 | # Lstsq chooses such point closest to 3D origin. 367 | corner_origs = [] 368 | for t1 in (0, 1): 369 | for t2 in (0, 1): 370 | inter_orig, res, rank, s = np.linalg.lstsq( 371 | [sheet1.normal(t1), sheet2.normal(t2)], 372 | [sheet1.offset(t1), sheet2.offset(t2)], 373 | rcond=None, 374 | ) 375 | corner_origs.append(inter_orig) 376 | 377 | # XXX Future 378 | # # Figure out shortening of teeth 379 | # d0 = np.linalg.norm(corner_origs[0] - corner_origs[3]) 380 | # d1 = np.linalg.norm(corner_origs[1] - corner_origs[2]) 381 | # 382 | # if d0 > params['max_finger_length']: 383 | # elif d1 > parmas['max_finger_length']: 384 | 385 | inter = Intersection(id=id, direction=inter_dir, origins=corner_origs,) 386 | 387 | inter_sides = [ 388 | create_inter_side(sp, inter, sheet, idx) 389 | for (idx, sheet) in enumerate(sheets) 390 | ] 391 | 392 | inter = inter.set("sides", tuple(inter_sides)) 393 | 394 | return sp.transform( 395 | ("intersections",), lambda inters: inters.set(id, inter) 396 | ) 397 | 398 | 399 | def intersection_id(sheets): 400 | return ",".join(list(sorted([sheet.id for sheet in sheets]))) 401 | 402 | 403 | def create_inter_side(sp, inter, sheet, idx): 404 | id = (inter.id, sheet.id) 405 | 406 | direction = sheet.project_point4(list(inter.direction) + [0.0]).astype( 407 | np.float64 408 | ) 409 | direction = vFraction(direction) 410 | normal = direction[::-1] * [1, -1] 411 | 412 | sheet_inter_origs = [ 413 | sheet.project_point4(list(orig) + [1.0]) for orig in inter.origins 414 | ] 415 | sheet_inter_origs = vFraction(sheet_inter_origs) 416 | 417 | normal_offsets = [np.dot(orig, normal) for orig in sheet_inter_origs] 418 | 419 | return InterSide( 420 | id=id, 421 | intersection=inter.id, 422 | sheet=sheet.id, 423 | idx=idx, 424 | direction=direction, 425 | normal=normal, 426 | origin_offset=direction.dot(sheet_inter_origs[0]), 427 | min_normal_offset=np.min(normal_offsets), 428 | max_normal_offset=np.max(normal_offsets), 429 | ) 430 | -------------------------------------------------------------------------------- /plycutter/geometry/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/geometry/aabb.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """ 20 | Axis-aligned bounding boxes 21 | """ 22 | 23 | import numpy as np 24 | import math 25 | 26 | 27 | def NP(v): 28 | return np.array(v, object) 29 | 30 | 31 | class AABB: 32 | """Axis-aligned bounding box of points""" 33 | 34 | def __init__(self, lower=None, upper=None): 35 | self.lower = lower 36 | self.upper = upper 37 | # Use NaNs cleverly to have less special cases 38 | if self.lower is None: 39 | self.lower = [math.nan, math.nan] 40 | if self.upper is None: 41 | self.upper = [math.nan, math.nan] 42 | self.lower = NP(self.lower) 43 | self.upper = NP(self.upper) 44 | self._update_middle() 45 | 46 | def _update_middle(self): 47 | self.middle = (self.lower + self.upper) / 2 48 | 49 | def include_point(self, pt): 50 | """Update this AABB to include the given point. 51 | """ 52 | if self.contains(pt): 53 | return 54 | for i in range(2): 55 | if not (pt[i] <= self.upper[i]): 56 | self.upper[i] = pt[i] 57 | if not (pt[i] >= self.lower[i]): 58 | self.lower[i] = pt[i] 59 | self._update_middle() 60 | 61 | def contains(self, pt): 62 | return np.all(pt <= self.upper) and np.all(pt >= self.lower) 63 | 64 | def intersects_aabb(self, other): 65 | return np.all(self.upper >= other.lower) and np.all( 66 | other.upper >= self.lower 67 | ) 68 | 69 | def intersects_segment(self, segment): 70 | """Determine whether the segment intersects this AABB. 71 | 72 | segment -- line segment represented as ((x0, y0), (x1, y1)) 73 | """ 74 | # Quick rejects 75 | if np.any((segment[0] < self.lower) & (segment[1] < self.lower)): 76 | return False 77 | if np.any((segment[0] > self.upper) & (segment[1] > self.upper)): 78 | return False 79 | 80 | # Separating line tests 81 | norm = (NP(segment[1]) - segment[0])[::-1] * (1, -1) 82 | 83 | mid_dot = np.dot(norm, self.middle) 84 | seg_dot = np.dot(norm, segment[0]) 85 | 86 | dot_diff = mid_dot - seg_dot 87 | 88 | d2 = dot_diff * dot_diff 89 | 90 | rhs = sum(abs(norm) * (self.upper - self.middle)) ** 2 91 | 92 | return d2 <= rhs 93 | 94 | def __repr__(self): 95 | return f"AABB({self.lower}...{self.upper})" 96 | -------------------------------------------------------------------------------- /plycutter/geometry/geom1d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import math 20 | 21 | from ..plytypes import FractionFromExact, \ 22 | FractionFromExactOrInf, fastr 23 | 24 | 25 | class Geom1D: 26 | """Immutable sum of closed-open intervals of an 1D line. 27 | 28 | Supports the set operations ``&``, ``|``, ``^``, ``-`` 29 | as well as ``~``.. 30 | 31 | Basically, open intervals except that a union 32 | of (0, 1) and (1, 2) is (0, 2). One way to accomplish this 33 | theoretically is by not including the constructible numbers 34 | in the domain. 35 | 36 | So none of the exact numbers 0, 1 or 2 can ever be part of the 37 | infinite set of numbers contained there, only 38 | numbers that are greater or less, so then the middle point 39 | gets removed nicely. 40 | 41 | A simpler, equivalent definition is to use lower-bound closed, 42 | upper-bound open intervals a la Python ranges but the above 43 | is more symmetric in definition wrt the real numbers. :) 44 | 45 | Note that - is defined as set difference. 46 | 47 | The intervals can contain infinities. 48 | 49 | g = Geom1D([[0, 1], [2, 3]]) 50 | g.locate(-1) --> -1 51 | g.locate(0) --> 0 52 | g.locate(0.5) --> 1 53 | g.locate(1.5) --> 0 54 | 55 | h = Geom1D([[1, 3]]) 56 | 57 | i = g & h # Equivalent to Geom1D([[2, 3]]) 58 | j = g | h # Equivalent to Geom1D([[0, 3]]) 59 | k = g - h # Equivalent to Geom1D([[0, 1]]) 60 | l = g ^ h # Equivalent to Geom1D([[0, 2]]) 61 | m = ~h # Equivalent to Geom1D([[-inf, 1], [3, inf]]) 62 | """ 63 | 64 | def __init__(self, intervals): 65 | intervals = sorted(intervals) 66 | 67 | # Check and convert 68 | new_intervals = [] 69 | for interval in intervals: 70 | assert len(interval) == 2 71 | assert interval[0] <= interval[1] 72 | 73 | new_intervals.append([FractionFromExactOrInf(v) for v in interval]) 74 | intervals = new_intervals 75 | 76 | # Normalize 77 | res = [] 78 | prev = None 79 | for interval in intervals: 80 | if interval[0] == interval[1]: 81 | continue 82 | 83 | if prev is None: 84 | prev = interval 85 | else: 86 | if prev[1] >= interval[0]: 87 | if interval[1] > prev[1]: 88 | prev = (prev[0], interval[1]) 89 | else: 90 | res.append(prev) 91 | prev = interval 92 | if prev is not None: 93 | res.append(prev) 94 | 95 | res = [tuple(r) for r in res] 96 | 97 | self.intervals = res 98 | 99 | def locate(self, point): 100 | """ 101 | 1 = in 102 | -1 = out 103 | 0 = indeterminate, possibly on edge, 104 | possibly on internal, virtual edge 105 | """ 106 | for interval in self.intervals: 107 | if interval[0] == point or interval[1] == point: 108 | return 0 109 | if interval[0] < point < interval[1]: 110 | return 1 111 | if interval[0] > point: 112 | break 113 | return -1 114 | 115 | def disjoint_pieces(self): 116 | """Return separate Geom1D pieces for disjoint intervals in self.""" 117 | return [Geom1D([interval]) for interval in self.intervals] 118 | 119 | def __repr__(self): 120 | # fastr is not ideal but neither is the long representation... 121 | return "Geom1D(%s)" % (", ".join([fastr(interval) 122 | for interval in self.intervals])) 123 | 124 | def __fstr__(self, **args): 125 | return "Geom1D(%s)" % (", ".join([fastr(interval, **args) 126 | for interval in self.intervals])) 127 | 128 | def get_intervals(self): 129 | return self.intervals 130 | 131 | @classmethod 132 | def empty(self): 133 | """Return an empty Geom1D.""" 134 | return Geom1D([]) 135 | 136 | def is_empty(self): 137 | """Return true if this Geom1D is empty, i.e. 0-measure.""" 138 | return len(self.intervals) == 0 139 | 140 | @classmethod 141 | def full(self): 142 | """Return a Geom1D that covers the full real line.""" 143 | return Geom1D([[-math.inf, math.inf]]) 144 | 145 | def buffer(self, amount): 146 | """Minkowski sum with the interval [-amount, amount]. 147 | 148 | Amount can be negative to reduce the area. 149 | """ 150 | amount = FractionFromExact(amount) 151 | intervals = [] 152 | for interval in self.intervals: 153 | prop = [interval[0] - amount, interval[1] + amount] 154 | if prop[1] <= prop[0]: 155 | continue 156 | intervals.append(prop) 157 | return Geom1D(intervals) 158 | 159 | def measure1d(self): 160 | """Return the 1d measure (in length) of this object""" 161 | return sum([b - a for (a, b) in self.intervals]) 162 | 163 | def is_bounded(self): 164 | """Return true if the region described by this Geom1D is bounded. 165 | """ 166 | return math.isfinite(self.measure1d()) 167 | 168 | def __or__(self, other): 169 | return Geom1D(self.intervals + other.intervals) 170 | 171 | def __and__(self, other): 172 | return ~((~self) | (~other)) # :) 173 | 174 | def __sub__(self, other): 175 | return self & (~other) 176 | 177 | def __xor__(self, other): 178 | return ((~self) & other) | (self & (~other)) 179 | 180 | def __invert__(self): 181 | intervals = [-math.inf] 182 | for interval in self.intervals: 183 | intervals = intervals + list(interval) 184 | intervals = intervals + [math.inf] 185 | 186 | return Geom1D(zip(intervals[0::2], intervals[1::2])) 187 | 188 | def filter(self, f): 189 | """Filter all disjoint intervals in this Geom1D through the function 190 | and return a new Geom1D with those intervals that pass the filter. 191 | """ 192 | return Geom1D([interval for interval in self.intervals 193 | if f(*interval)]) 194 | -------------------------------------------------------------------------------- /plycutter/geometry/geom2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from . import geom2dbase 20 | from .impl2d import geom2d_simple_holes 21 | 22 | Geom2DBase = geom2dbase.Geom2DBase 23 | Geom2D = geom2d_simple_holes.Geom2D_Py_Simple_Holes 24 | -------------------------------------------------------------------------------- /plycutter/geometry/geom2dbase.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from abc import abstractmethod 20 | import matplotlib 21 | import matplotlib.path 22 | import matplotlib.patches 23 | 24 | 25 | class Geom2DBase: 26 | """A set of semi-open convex polygons. 27 | 28 | (called regularized polygons in CGAL) 29 | 30 | Supports the set operations ``&``, ``|``, ``^``, ``-`` 31 | as well as ``~``.. 32 | 33 | Like Geom1D, we get a convenient set of properties 34 | by asserting that the enclosing lines and 35 | the contents are in different spaces ( 36 | e.g. rationals and rationals + sqrt(2)) 37 | so that unions and differences work in 38 | the most natural way possible. 39 | """ 40 | 41 | @classmethod 42 | def polygon(cls, points, reorient=False): 43 | pass 44 | 45 | @classmethod 46 | def union(cls, geoms): 47 | raise Exception() 48 | 49 | @classmethod 50 | def rectangle(cls, x0, y0, x1, y1): 51 | return cls.polygon( 52 | [(x0, y0), (x0, y1), (x1, y1), (x1, y0),], reorient=True 53 | ) 54 | 55 | @abstractmethod 56 | def __or__(self, other): 57 | pass 58 | 59 | @abstractmethod 60 | def __and__(self, other): 61 | pass 62 | 63 | @abstractmethod 64 | def __sub__(self, other): 65 | pass 66 | 67 | @abstractmethod 68 | def locate(self, pt): 69 | """Locate point wrt polyset 70 | 71 | 1 = in 72 | -1 = out 73 | 0 = indeterminate, possibly on edge, 74 | possibly on internal, virtual edge 75 | 76 | """ 77 | 78 | @abstractmethod 79 | def all_segments(self): 80 | """Iterate over all line segments defining this geom. 81 | Depending on representation, 82 | may include internal segments that are not 83 | actual external boundary. 84 | """ 85 | 86 | @abstractmethod 87 | def polygons(self): 88 | """Iterate over all distinct polygons (with holes) 89 | in this geom. 90 | """ 91 | 92 | @abstractmethod 93 | def exterior(self): 94 | """Get the outer boundary as a list of vertices, 95 | provided this is a single polygon. 96 | """ 97 | 98 | @abstractmethod 99 | def holes(self): 100 | """Iterator over the vertex lists of holes, 101 | provided this is a single polygon. 102 | """ 103 | 104 | def to_mpatch( 105 | self, color="red", alpha=1.0, label=None, linewidth=0, ax=None 106 | ): 107 | """Convert to matplotlib.patches.PathPatch""" 108 | codes = [] 109 | verts = [] 110 | # print('MPATCH', self) 111 | 112 | def add_path(new_verts): 113 | new_verts = list(new_verts) 114 | assert len(new_verts) > 1 115 | new_verts = new_verts + new_verts[0:1] 116 | codes.extend( 117 | [matplotlib.path.Path.MOVETO] 118 | + (len(new_verts) - 1) * [matplotlib.path.Path.LINETO] 119 | ) 120 | verts.extend(new_verts) 121 | 122 | for poly in self.polygons(): 123 | ext = poly.exterior() 124 | add_path(ext) 125 | 126 | for hole in poly.holes(): 127 | # print('HOLE', hole) 128 | add_path(reversed(hole)) 129 | 130 | if len(verts): 131 | path = matplotlib.path.Path(verts, codes) 132 | patch = matplotlib.patches.PathPatch( 133 | path, 134 | fill=True, 135 | facecolor=color, 136 | edgecolor="black", 137 | label=label, 138 | alpha=alpha, 139 | linewidth=linewidth, 140 | ) 141 | else: 142 | patch = None 143 | 144 | if ax is not None: 145 | ax.add_patch(patch) 146 | ax.autoscale_view() 147 | return patch 148 | 149 | def show2d(self, ax, color, alpha=1.0, label=None, linewidth=0): 150 | """Plot this Geom2D onto the given matplotlib Axes object. 151 | """ 152 | patch = self.to_mpatch( 153 | color=color, alpha=alpha, label=label, linewidth=linewidth 154 | ) 155 | if patch is not None: 156 | ax.add_patch(patch) 157 | ax.set_aspect(1) 158 | ax.autoscale_view() 159 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/cell_merger.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import collections 20 | from itertools import islice 21 | 22 | from ...plytypes import fstr 23 | 24 | 25 | def ifirst(iterator): 26 | return list(islice(iterator, 1))[0] 27 | 28 | 29 | def canonicalize_cell(cell): 30 | mini = 0 31 | for i in range(len(cell)): 32 | if cell[i] < cell[mini]: 33 | mini = i 34 | return list(cell[mini:]) + list(cell[:mini]) 35 | 36 | 37 | class CellMergerSlow: 38 | """Given directed circular vertex lists, merge opposite half-edges. 39 | 40 | Keeps track of the original cells merged into a given 41 | final cell. 42 | 43 | """ 44 | 45 | class Cell: 46 | def __init__(self, cell, origs): 47 | self.vertices = cell 48 | self.half_edges = collections.defaultdict(lambda: 0) 49 | self.origs = origs 50 | 51 | n = len(cell) 52 | for i in range(n): 53 | v0 = cell[i] 54 | v1 = cell[(i + 1) % n] 55 | self.half_edges[v0, v1] += 1 56 | 57 | def __repr__(self): 58 | return f"CELL({fstr(self.vertices), fstr(self.origs)})" 59 | 60 | def merged(self, other): 61 | self_half_edges = {**self.half_edges} 62 | other_half_edges = {**other.half_edges} 63 | changed = False 64 | for (v0, v1), v in list(self_half_edges.items()): 65 | assert (v0, v1) not in other_half_edges 66 | if (v1, v0) in other_half_edges: 67 | del self_half_edges[v0, v1] 68 | del other_half_edges[v1, v0] 69 | changed = True 70 | if not changed: 71 | return None 72 | 73 | # Can have multiple contacts... 74 | nxt = collections.defaultdict(lambda: set()) 75 | for hes in (self_half_edges, other_half_edges): 76 | for v0, v1 in hes: 77 | nxt[v0].add(v1) 78 | 79 | res = [] 80 | # print('merge', fstr(self_half_edges, other_half_edges)) 81 | # print('nxt', fstr(nxt)) 82 | while len(nxt): 83 | lst = [] 84 | k0, vs = nxt.popitem() 85 | lst.append(k0) 86 | v = ifirst(vs) 87 | if len(vs) > 1: 88 | nxt[k0] = vs - {v} 89 | while v != k0: 90 | # print(v) 91 | lst.append(v) 92 | vs = nxt.pop(v) 93 | vnext = ifirst(vs) 94 | if len(vs) > 1: 95 | nxt[v] = vs - {vnext} 96 | v = vnext 97 | res.append( 98 | CellMergerSlow.Cell(tuple(lst), self.origs + other.origs) 99 | ) 100 | return res 101 | 102 | def __init__(self): 103 | self.cells = set() 104 | 105 | # All the original cells added, for debugging 106 | self.original_cells = set() 107 | self.original_cells_list = [] 108 | 109 | self.annihilated_original_cells = set() 110 | 111 | def add_cell(self, cell): 112 | assert cell not in self.original_cells 113 | self.original_cells.add(cell) 114 | self.original_cells_list.append(cell) 115 | 116 | cell = self.Cell(cell, (cell,)) 117 | # print('Add', fstr(cell)) 118 | 119 | to_add = [cell] 120 | while len(to_add): 121 | cell = to_add.pop() 122 | 123 | deleted = False 124 | 125 | for other in list(self.cells): 126 | m = cell.merged(other) 127 | if m is not None: 128 | self.cells.remove(other) 129 | if len(m) == 0: 130 | # Special case: annihilation. 131 | # Need to add both original cells 132 | # to all other groups with those original 133 | # cells 134 | 135 | assert len(set(cell.origs) & set(other.origs)) == 0 136 | orig_cells = set(cell.origs) | set(other.origs) 137 | saved = False 138 | for outside_cell in self.cells: 139 | if set(outside_cell.origs) & orig_cells: 140 | # Changes the value inside the obj 141 | # --> the list above is ok 142 | outside_cell.origs = tuple( 143 | set(outside_cell.origs) | orig_cells 144 | ) 145 | saved = True 146 | 147 | if not saved: 148 | self.annihilated_original_cells |= orig_cells 149 | deleted = True 150 | break 151 | cell = m[0] 152 | to_add.extend(m[1:]) 153 | 154 | if deleted: 155 | continue 156 | 157 | self.cells.add(cell) 158 | 159 | # print('Cells after', fstr(self.cells)) 160 | 161 | def get_cells(self): 162 | self.check() 163 | return [cell.vertices for cell in self.cells] 164 | 165 | def get_cells_originals(self): 166 | self.check() 167 | return {cell.vertices: cell.origs for cell in self.cells} 168 | 169 | def check(self): 170 | origs = set() 171 | for cell in self.cells: 172 | origs |= set(cell.origs) 173 | assert not (origs & self.annihilated_original_cells) 174 | origs |= self.annihilated_original_cells 175 | 176 | if origs != self.original_cells: 177 | 178 | assert origs == self.original_cells, [ 179 | self.original_cells_list, 180 | (self.original_cells, origs), 181 | ] 182 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/frangle2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from ..types2d import Coord, Point, Vector 20 | 21 | """Fractional angles: unit-circle analogues of radians 22 | 23 | Useful for for example sorting. 24 | """ 25 | 26 | 27 | MAX_FRANGLE = 8 28 | """Frangle corresponding to full circle (square)""" 29 | 30 | FRANGLE_90 = 2 31 | """Frangle corresponding to 90 degrees. 32 | 33 | Unlike most frangles, this is conveniently invariant: 34 | adding this to a frangle will rotate it by exactly 90 35 | degrees.""" 36 | 37 | FRANGLE_180 = 4 38 | """Frangle corresponding to 180 degrees. 39 | 40 | Unlike most frangles, this is conveniently invariant: 41 | adding this to a frangle will rotate it by exactly 90 42 | degrees.""" 43 | 44 | 45 | def vector_frangle(vec: Point) -> Coord: 46 | """An fractional angle for order key for line; sorting by these 47 | will yield a clockwise order starting from vertical line. 48 | 49 | Kind of like an angle but rational, goes from 0 to 8 along 50 | the (-1, -1) .. (1, 1) square 51 | 52 | Properties of frangles: 53 | 54 | fa - fb == 8 --> 360 degrees 55 | fa - fb == 4 --> 180 degrees 56 | fa - fb == 2 --> 90 degrees 57 | 58 | For 45 degrees, the formula would not be constant 59 | """ 60 | assert vec[0] != 0 or vec[1] != 0 61 | 62 | if abs(vec[0]) > abs(vec[1]): 63 | order = abs(vec[1] / vec[0]) 64 | else: 65 | order = 2 - abs(vec[0] / vec[1]) 66 | 67 | if vec[0] >= 0: 68 | if vec[1] <= 0: 69 | return order 70 | else: 71 | return 8 - order 72 | else: 73 | if vec[1] <= 0: 74 | return 4 - order 75 | else: 76 | return 4 + order 77 | 78 | 79 | def frangle_unit_square_vector(frangle: Coord) -> Vector: 80 | """Get a vector at frangle on the unit square. 81 | """ 82 | frangle = frangle % 8 83 | 84 | frhalf = frangle % 4 85 | 86 | frquad = abs(2 - frhalf) 87 | 88 | if frquad < 1: 89 | v = (frquad, -1) 90 | else: 91 | v = (1, -(2 - frquad)) 92 | 93 | if frhalf > 2: 94 | v = (-v[0], v[1]) 95 | 96 | if frangle >= 4: 97 | v = (-v[0], -v[1]) 98 | 99 | return v 100 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/geom2d_simple_holes.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import logging 20 | import numpy as np 21 | import operator 22 | import math 23 | from itertools import islice 24 | from sortedcontainers import SortedDict 25 | import functools 26 | 27 | from ...plytypes import ( 28 | Fraction, 29 | FractionClass, 30 | FractionFromExact, 31 | FractionFromFloat, 32 | fstr, 33 | totuple, 34 | ) 35 | from ..geom1d import Geom1D 36 | 37 | from .primitive2d import ( 38 | edges_iter, 39 | simple_polygon_area, 40 | locate_point_polygon_winding_number, 41 | ) 42 | from .. import geom2dbase 43 | from . import arrangement2d 44 | from .frangle2d import ( 45 | frangle_unit_square_vector, 46 | vector_frangle, 47 | MAX_FRANGLE, 48 | FRANGLE_180, 49 | ) 50 | 51 | 52 | def NP(v): 53 | return np.array(v, object) 54 | 55 | 56 | def L(v): 57 | return np.array(v, object).tolist() 58 | 59 | 60 | def buffer_of_size(sz, n=8): 61 | sz = FractionFromExact(sz) 62 | buffer_polygon = [] 63 | for i in range(n): 64 | # Use frangles to be exactly 65 | # the same and symmetric on all quadrants 66 | frangle = i * Fraction(MAX_FRANGLE) / n 67 | v = frangle_unit_square_vector(frangle) 68 | vlen2 = sum(NP(v) ** 2) 69 | # This is the approximate vector length. 70 | # TODO: search for pythagorean triples to 71 | # avoid this :) 72 | vlen = Fraction(FractionFromFloat(math.sqrt(float(vlen2)), -10)) / sz 73 | buffer_polygon.append((v[0] / vlen, v[1] / vlen)) 74 | return buffer_polygon 75 | 76 | 77 | def fr_points(points): 78 | res = [] 79 | for p in points: 80 | assert len(p) == 2 81 | res.append((Fraction(p[0]), Fraction(p[1]))) 82 | return res 83 | 84 | 85 | def check_no_dups(verts): 86 | for v0, v1 in edges_iter(verts): 87 | assert np.any(v0 != v1), (v0, v1, verts) 88 | 89 | 90 | class Simple_Polygon_With_Holes: 91 | """Requires clockwise polygons 92 | """ 93 | 94 | def __init__(self, outer, holes=[]): 95 | check_no_dups(outer) 96 | a = simple_polygon_area(outer) 97 | assert a > 0, a 98 | self.outer = outer 99 | for hole in holes: 100 | assert simple_polygon_area(hole) > 0 101 | check_no_dups(hole) 102 | self.holes = holes 103 | 104 | def repro(self): 105 | """Gets so long that not default""" 106 | return "plycutter.geom2d_py.Simple_Polygon_With_Holes(%s,%s)" % ( 107 | L(self.outer), 108 | L(self.holes), 109 | ) 110 | 111 | def transformed_with(self, f): 112 | def fpoly(p): 113 | p = f(p) 114 | if simple_polygon_area(p) < 0: 115 | p = list(reversed(p)) 116 | return p 117 | 118 | return self.__class__( 119 | fpoly(self.outer), [fpoly(hole) for hole in self.holes] 120 | ) 121 | 122 | def segments(self): 123 | yield from edges_iter(self.outer) 124 | for hole in self.holes: 125 | yield from edges_iter(hole) 126 | 127 | def area(self): 128 | a = simple_polygon_area(self.outer) 129 | for hole in self.holes: 130 | a = a - simple_polygon_area(hole) 131 | return a 132 | 133 | def locate(self, point): 134 | w = locate_point_polygon_winding_number(self.outer, point) 135 | # print('loc', fstr(point, w, approx=True)) 136 | if w == -1: 137 | return w 138 | if w == 0: 139 | return w 140 | 141 | for hole in self.holes: 142 | h = locate_point_polygon_winding_number(hole, point) 143 | # print('hloc', fstr(point, h, approx=True)) 144 | if h == 0: 145 | return 0 146 | if h > 0: 147 | return -1 148 | return 1 149 | 150 | 151 | oplogger = logging.getLogger("geom2dops") 152 | oplogger.setLevel(logging.INFO) 153 | 154 | arrangement_check = True 155 | 156 | UNION_N = 64 157 | 158 | 159 | class Geom2D_Py_Simple_Holes(geom2dbase.Geom2DBase): 160 | def __init__(self, spwhs): 161 | assert len(spwhs) >= 0 # "typecheck" 162 | self.spwhs = spwhs 163 | 164 | def repro(self): 165 | """This gets so long that it is not the default""" 166 | return "plycutter.geom2d_py.Geom2D_Py_Simple_Holes([%s])" % ",".join( 167 | [spwh.repro() for spwh in self.spwhs] 168 | ) 169 | 170 | def _arrangement(self, geoms): 171 | global ggeoms 172 | ggeoms = geoms 173 | segs = {} 174 | n = 0 175 | for geom in geoms: 176 | for segment in geom.all_segments(): 177 | segs["s%d" % n] = segment 178 | n += 1 179 | 180 | arr = arrangement2d.SegmentArrangement(segs) 181 | return arr 182 | 183 | def _arrangement_op(self, geoms, op): 184 | arr = self._arrangement(geoms) 185 | 186 | cells = set() 187 | cell_points = arr.cell_points() 188 | for cell, pt in cell_points.items(): 189 | locs = [geom.locate(pt) for geom in geoms] 190 | for loc in locs: 191 | assert loc == -1 or loc == 1 192 | # print('ARRCELL', fstr(cell), locs) 193 | if op(*[loc > 0 for loc in locs]): 194 | cells.add(cell) 195 | 196 | polys = arr.poly_cells(cells) 197 | result = Geom2D_Py_Simple_Holes(polys) 198 | 199 | if arrangement_check: 200 | # Check that we really got what we nitended 201 | for cell, pt in cell_points.items(): 202 | locs = [geom.locate(pt) for geom in geoms] 203 | for loc in locs: 204 | assert loc == -1 or loc == 1 205 | # print('ARRCELL', fstr(cell), locs) 206 | new_loc = result.locate(pt) 207 | if op(*[loc > 0 for loc in locs]): 208 | assert new_loc > 0, fstr(pt, new_loc, approx=True) 209 | else: 210 | assert new_loc < 0, fstr(pt, new_loc, approx=True) 211 | 212 | return result 213 | 214 | @classmethod 215 | def empty(cls): 216 | return Geom2D_Py_Simple_Holes([]) 217 | 218 | @classmethod 219 | def union(cls, geoms): 220 | if len(geoms) > UNION_N: 221 | return union_tree(geoms) 222 | return cls.empty()._arrangement_op(geoms, lambda *args: any(args)) 223 | 224 | def is_empty(self): 225 | return len(self.spwhs) == 0 226 | 227 | def area(self): 228 | return sum(spwh.area() for spwh in self.spwhs) 229 | 230 | @classmethod 231 | def polygon(cls, points, reorient=False): 232 | # print(points) 233 | for point in points: 234 | for coord in point: 235 | assert ( 236 | isinstance(coord, FractionClass) or int(coord) == coord 237 | ), (coord, points) 238 | points = fr_points(points) 239 | # print(points) 240 | 241 | if reorient and simple_polygon_area(points) < 0: 242 | points = list(reversed(points)) 243 | 244 | result = Geom2D_Py_Simple_Holes([Simple_Polygon_With_Holes(points)]) 245 | assert result.area() >= 0, result.area() 246 | return result 247 | 248 | @classmethod 249 | def from_shapely(cls, sh): 250 | if sh.geom_type == "GeometryCollection": 251 | geoms = [cls.from_shapely_polygon(geom) for geom in sh.geoms] 252 | spwhs = [] 253 | for geom in geoms: 254 | assert len(geom.spwhs) == 1 255 | spwhs.extend(geom.spwhs) 256 | return cls(spwhs) 257 | if sh.geom_type == "Polygon": 258 | return cls.from_shapely_polygon(sh) 259 | if sh.geom_type == "MultiPolygon": 260 | geoms = [cls.from_shapely_polygon(poly) for poly in sh.geoms] 261 | spwhs = [] 262 | for geom in geoms: 263 | assert len(geom.spwhs) == 1 264 | spwhs.extend(geom.spwhs) 265 | return cls(spwhs) 266 | raise Exception(f"Invalid geom type {sh.geom_type}") 267 | 268 | @classmethod 269 | def from_shapely_polygon(cls, sh): 270 | import shapely 271 | 272 | sh = shapely.geometry.polygon.orient(sh, -1) 273 | 274 | def sh_points(pts): 275 | pts = fr_points(pts) 276 | assert pts[0] == pts[-1] 277 | return pts[:-1] 278 | 279 | result = cls( 280 | [ 281 | Simple_Polygon_With_Holes( 282 | sh_points(sh.exterior.coords), 283 | [ 284 | list(reversed(sh_points(hole.coords))) 285 | for hole in sh.interiors 286 | ], 287 | ) 288 | ] 289 | ) 290 | assert result.area() >= 0, result.area() 291 | return result 292 | 293 | def __or__(self, other): 294 | if self.is_empty(): 295 | if oplogger.isEnabledFor(logging.DEBUG): 296 | oplogger.debug("or shortcut %s", other.area()) 297 | return other 298 | if other.is_empty(): 299 | if oplogger.isEnabledFor(logging.DEBUG): 300 | oplogger.debug("or shortcut %s", self.area()) 301 | return self 302 | if oplogger.isEnabledFor(logging.DEBUG): 303 | oplogger.debug("or %s %s", self.area(), other.area()) 304 | return self._arrangement_op([self, other], operator.or_) 305 | 306 | def __and__(self, other): 307 | if oplogger.isEnabledFor(logging.DEBUG): 308 | oplogger.debug("and %s %s", self.area(), other.area()) 309 | return self._arrangement_op([self, other], operator.and_) 310 | 311 | def __sub__(self, other): 312 | if other.is_empty(): 313 | if oplogger.isEnabledFor(logging.DEBUG): 314 | oplogger.debug("sub shortcut %s", self.area()) 315 | return self 316 | if oplogger.isEnabledFor(logging.DEBUG): 317 | oplogger.debug("sub %s %s", self.area(), other.area()) 318 | 319 | def oper(a, b): 320 | # print('suboper', a, b) 321 | if a and not b: 322 | return True 323 | return False 324 | 325 | return self._arrangement_op([self, other], oper) 326 | 327 | def buffer(self, amount, resolution=8): 328 | global locs 329 | locs = locals() # DBG 330 | if oplogger.isEnabledFor(logging.DEBUG): 331 | oplogger.debug( 332 | "buffer %s %s", amount, len(list(self.all_segments())) 333 | ) 334 | if amount == 0: 335 | return self 336 | amount = FractionFromExact(amount) 337 | buf = buffer_of_size(abs(amount), n=resolution) 338 | res = self._minkowski_convex_op(buf, 1 if amount > 0 else -1) 339 | oplogger.debug("done") 340 | return res 341 | 342 | def all_segments(self): 343 | for poly in self.spwhs: 344 | yield from poly.segments() 345 | 346 | def locate(self, pt): 347 | if len(self.spwhs) == 0: 348 | return -1 349 | 350 | return max([poly.locate(pt) for poly in self.spwhs]) 351 | 352 | def polygons(self): 353 | return [self.__class__([spwh]) for spwh in self.spwhs] 354 | 355 | def exterior(self): 356 | assert len(self.spwhs) == 1 357 | return self.spwhs[0].outer 358 | 359 | def holes(self): 360 | assert len(self.spwhs) == 1 361 | yield from self.spwhs[0].holes 362 | 363 | def project_to_1d(self, vec, offs): 364 | res = Geom1D.empty() 365 | for spwh in self.spwhs: 366 | ds = np.dot(np.array(spwh.outer, object), vec) + offs 367 | res = res | Geom1D([(min(ds), max(ds))]) 368 | return res 369 | 370 | def transformed_with(self, f): 371 | return self.__class__( 372 | [spwh.transformed_with(f) for spwh in self.spwhs] 373 | ) 374 | 375 | def _minkowski_convex_op(self, cpoly, sign): 376 | cpoly = NP(cpoly) 377 | assert sign != 0 378 | # if sign < 0: 379 | # cpoly = cpoly * -1 380 | offset = NP((0, 0)) 381 | if locate_point_polygon_winding_number(cpoly, offset) != 1: 382 | offset = sign * NP(cpoly[0] + cpoly[1] + cpoly[2]) / 3 383 | cpoly = cpoly - sign * offset 384 | assert locate_point_polygon_winding_number(cpoly, (0, 0)) == 1 385 | # print('offset', offset) 386 | 387 | pos = [] 388 | neg = [] 389 | 390 | holes = [] 391 | 392 | for spwh in self.spwhs: 393 | pos.append( 394 | Geom2D_Py_Simple_Holes( 395 | [ 396 | Simple_Polygon_With_Holes( 397 | spwh.transformed_with(lambda v: v + offset).outer 398 | ) 399 | ] 400 | ) 401 | ) 402 | 403 | ext_simples = minkowski_ribbon_simple_convex( 404 | spwh.outer, cpoly, sign 405 | ) 406 | holes.extend(spwh.holes) 407 | 408 | def reorient(e): 409 | if simple_polygon_area(e) < 0: 410 | return list(reversed(e)) 411 | return e 412 | 413 | ext = [ 414 | Geom2D_Py_Simple_Holes( 415 | [Simple_Polygon_With_Holes(reorient(e) + offset)] 416 | ) 417 | for e in ext_simples 418 | ] 419 | if sign > 0: 420 | pos.extend(ext) 421 | else: 422 | neg.extend(ext) 423 | 424 | for hole in holes: 425 | g = Geom2D_Py_Simple_Holes([Simple_Polygon_With_Holes(hole)]) 426 | g = g._minkowski_convex_op(cpoly, -sign) 427 | neg.append(g) 428 | # print('Hole', g.area()) 429 | 430 | if True: 431 | # Reveals weird bugs... 432 | gpos = union_tree(pos) 433 | gneg = union_tree(neg) 434 | geom = gpos - gneg 435 | 436 | return geom 437 | 438 | npos = len(pos) 439 | nneg = len(neg) 440 | 441 | posmask = np.arange(npos + nneg) < npos 442 | negmask = ~posmask 443 | 444 | subs = pos + neg 445 | 446 | return self._arrangement_op( 447 | subs, lambda *v: np.any(posmask & v) & ~np.any(negmask & v) 448 | ) 449 | 450 | 451 | Geom2D_Py_Simple_Holes.__doc__ = geom2dbase.Geom2DBase.__doc__ 452 | 453 | error_geoms = None 454 | 455 | 456 | def union_tree(geoms): 457 | # N = 8 458 | while len(geoms) > 1: 459 | # print(sum([g.area() for g in geoms]), len(geoms)) 460 | nxt = [] 461 | for i in range(0, len(geoms), UNION_N): 462 | slc = geoms[i : i + UNION_N] 463 | if len(slc) > 1: 464 | try: 465 | nxt.append(Geom2D_Py_Simple_Holes.union(slc)) 466 | except Exception: 467 | global error_geoms 468 | error_geoms = slc 469 | raise 470 | else: 471 | assert len(slc) == 1 472 | nxt.append(slc[0]) 473 | # print('A', len(nxt)) 474 | geoms = nxt 475 | # print(sum([g.area() for g in geoms]), len(geoms)) 476 | if len(geoms) == 0: 477 | return Geom2D_Py_Simple_Holes.empty() 478 | return geoms[0] 479 | 480 | 481 | def _ifirst(iterator): 482 | return list(islice(iterator, 1))[0] 483 | 484 | 485 | def _triple_vertices_iter(cell): 486 | n = len(cell) 487 | for i in range(n): 488 | yield (cell[(i - 1) % n], cell[i], cell[(i + 1) % n]) 489 | 490 | 491 | def minkowski_ribbon_simple_convex(spoly, cpoly, sign): # noqa # XXX 492 | """Generate the simple polygons, the union of which 493 | are added or semoved to spoly. 494 | 495 | Assuming clockwise polygon that contains the origin 496 | """ 497 | # XXX This is way too complex... 498 | assert len(cpoly) > 2 499 | assert sign == -1 or sign == 1 500 | 501 | # print(fstr(spoly)) 502 | # print(fstr(cpoly)) 503 | # print(sign) 504 | 505 | edge_start_by_frangle = SortedDict() 506 | for i, (v0, v1) in enumerate(edges_iter(cpoly)): 507 | frangle = vector_frangle(NP(v1) - v0) 508 | assert frangle >= 0 509 | if frangle in edge_start_by_frangle: 510 | # Leave out if it is already in (degenerate 511 | # vertex at 180 degree angle) 512 | continue 513 | edge_start_by_frangle[frangle] = i 514 | # Avoid special cases by rolling this over 515 | edge_start_by_frangle[frangle + MAX_FRANGLE] = i 516 | edge_start_by_frangle[frangle - MAX_FRANGLE] = i 517 | 518 | parts = [] 519 | 520 | def find_vertex_on(frangle): 521 | fr = _ifirst( 522 | edge_start_by_frangle.irange( 523 | -8, frangle, inclusive=(True, True), reverse=True 524 | ) 525 | ) 526 | return (edge_start_by_frangle[fr] + 1) % len(cpoly) 527 | 528 | # Generate the corner pieces + v..vn edge piece 529 | for vp, v, vn in _triple_vertices_iter(spoly): 530 | pfr = vector_frangle(NP(v) - vp) 531 | nfr = vector_frangle(NP(vn) - v) 532 | 533 | # Find the cpoly vertex visible on the edges 534 | iprev = find_vertex_on(pfr) 535 | inext = find_vertex_on(nfr) 536 | 537 | others = [NP(vn) + sign * NP(cpoly[inext]), vn, v] 538 | if (nfr - pfr) % MAX_FRANGLE < FRANGLE_180: 539 | # Turn right 540 | idxs = np.arange( 541 | iprev, 1 + (inext if inext >= iprev else inext + len(cpoly)) 542 | ) 543 | if sign < 0: 544 | idxs = np.array([inext]) 545 | else: 546 | # Turn left 547 | idxs = np.arange( 548 | inext, 1 + (iprev if iprev >= inext else iprev + len(cpoly)) 549 | ) 550 | if sign > 0: 551 | idxs = np.array([inext]) 552 | others = list(reversed(others)) 553 | 554 | idxs = idxs % len(cpoly) 555 | 556 | # print(fstr('VERT', (vp, v, vn))) 557 | # print(fstr(iprev, inext, idxs, others)) 558 | 559 | if sign < 0: 560 | idxs = idxs[::-1] 561 | others = list(reversed(others)) 562 | 563 | if len(idxs) == 0: 564 | continue 565 | 566 | # print(idxs) 567 | 568 | verts = [NP(v) + sign * NP(cpoly[idx]) for idx in idxs] 569 | # If non-convex by being too sharp corner (i.e. vertex would 570 | # "poke through", add the vertex there 571 | adds = set() 572 | for i in range(0, len(verts) - 1): 573 | if sign * simple_polygon_area((verts[i], verts[i + 1], v)) < 0: 574 | adds.add(i) 575 | if len(adds) > 0: 576 | # print('Reverse', sorted(adds)) 577 | for i in reversed(sorted(adds)): 578 | verts[i:i] = [v] 579 | 580 | verts = verts + others 581 | # print(fstr(verts)) 582 | for vert in verts: 583 | assert len(vert) == 2, vert 584 | 585 | verts = simplify_degeneracies(verts) 586 | # print(fstr('SIMP', verts)) 587 | 588 | if len(verts) > 2: 589 | # Generate the full polygon 590 | parts.append(verts) 591 | else: 592 | pass 593 | # print('NOGEN') 594 | 595 | return parts 596 | 597 | 598 | def simplify_degeneracies(verts): 599 | changed = True 600 | verts = list(totuple(verts)) 601 | 602 | @functools.lru_cache(maxsize=None) 603 | def area(a, b, c): 604 | return simple_polygon_area([a, b, c]) 605 | 606 | while changed: 607 | changed = False 608 | 609 | # Whenever the area of three vertices is zero, 610 | # we can remove the middle one 611 | 612 | i = 0 613 | nz = False 614 | while i < len(verts): 615 | n = len(verts) 616 | vpp = verts[(i - 2) % n] 617 | vp = verts[(i - 1) % n] 618 | v = verts[i] 619 | vn = verts[(i + 1) % n] 620 | 621 | if area(vp, v, vn) != 0: 622 | nz = True 623 | 624 | # print(fstr(i, v, area(vpp, vp, v), area(vp, v, vn))) 625 | if area(vpp, vp, v) != 0 and area(vp, v, vn) == 0: 626 | # Delete 627 | verts[i : i + 1] = [] 628 | changed = True 629 | else: 630 | i += 1 631 | 632 | # If all areas are zero, remove first. 633 | # Could still be a real polygon with just all vertices repeated 634 | if not nz and len(verts) > 0: 635 | verts[0:1] = [] 636 | changed = True 637 | 638 | return verts 639 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/pmr_quadtree.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from ..aabb import AABB 20 | 21 | 22 | def round_aabb(aabb): 23 | return aabb 24 | 25 | 26 | class PMRQuadTree: 27 | def __init__(self, aabb, intersects_aabb, items=[], threshold=4): 28 | """Create a new quadtree. 29 | 30 | Generic for different types of items. 31 | 32 | aabb - the main outside bounding box that must be known beforehand 33 | intersects_aabb - function(item, aabb) for items stored in this tree 34 | 35 | """ 36 | # Round the aabb upwards 37 | self.aabb = round_aabb(aabb) 38 | 39 | self.intersects_aabb = intersects_aabb 40 | self.threshold = threshold 41 | 42 | self.root = self.Node(self, aabb) 43 | for item in items: 44 | self.add(item) 45 | 46 | def add(self, item): 47 | self.root.add(item, True) 48 | 49 | def find_all_similar(self, item): 50 | return self.find_all(lambda aabb: self.intersects_aabb(item, aabb)) 51 | 52 | def find_all(self, intersects_aabb): 53 | """Find all objects that intersect certain aabbs in the tree.""" 54 | result = set() 55 | self.root.find_all(intersects_aabb, result) 56 | return result 57 | 58 | class Node: 59 | def __init__(self, main, aabb): 60 | self.main = main 61 | self.aabb = aabb 62 | self.items = set() 63 | self.children = None 64 | 65 | def add(self, item, allow_split): 66 | if not self.main.intersects_aabb(item, self.aabb): 67 | return 68 | if self.children is None: 69 | if allow_split: 70 | if len(self.items) + 1 >= self.main.threshold: 71 | self.split() 72 | allow_split = False 73 | 74 | if self.children is not None: 75 | for child in self.children: 76 | child.add(item, allow_split=allow_split) 77 | else: 78 | self.items.add(item) 79 | 80 | def split(self): 81 | # Split 82 | aabb_mid = (self.aabb.upper + self.aabb.lower) / 2 83 | 84 | self.children = [] 85 | 86 | for xr in ( 87 | (self.aabb.lower[0], aabb_mid[0]), 88 | (aabb_mid[0], self.aabb.upper[0]), 89 | ): 90 | for yr in ( 91 | (self.aabb.lower[1], aabb_mid[1]), 92 | (aabb_mid[1], self.aabb.upper[1]), 93 | ): 94 | aabb = AABB((xr[0], yr[0]), (xr[1], yr[1])) 95 | self.children.append(self.main.Node(self.main, aabb)) 96 | 97 | # for item in self.items: 98 | # for child in self.children: 99 | # child.add(item, allow_split=False) 100 | 101 | def find_all(self, aabb_predicate, result): 102 | if not aabb_predicate(self.aabb): 103 | return 104 | if self.items is not None: 105 | result |= self.items 106 | if self.children is not None: 107 | for child in self.children: 108 | child.find_all(aabb_predicate, result) 109 | 110 | 111 | class SegmentPMRQuadTree: 112 | def __init__(self, aabb=None, items=[], threshold=4): 113 | if aabb is None: 114 | aabb = AABB() 115 | for v0, v1 in items: 116 | aabb.include_point(v0) 117 | aabb.include_point(v1) 118 | self.pmr = PMRQuadTree( 119 | aabb, 120 | lambda segment, aabb: aabb.intersects_segment(segment), 121 | items, 122 | threshold, 123 | ) 124 | 125 | def find(self, segment): 126 | return self.pmr.find_all_similar(segment) 127 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/primitive2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import numpy as np 20 | 21 | 22 | def segment_segment_general_intersection(l0, l1): 23 | """Only when segments are in general position. 24 | Returns (pt, t0, t1). 25 | 26 | Performance is key so we don't use numpy here. 27 | """ 28 | 29 | # denom = det(np.stack((l0[1] - l0[0], l1[1] - l1[0]), axis=1)) 30 | denom = (l0[1][0] - l0[0][0]) * (l1[1][1] - l1[0][1]) - ( 31 | l0[1][1] - l0[0][1] 32 | ) * (l1[1][0] - l1[0][0]) 33 | 34 | assert denom != 0 35 | 36 | # Normal case, nothing to see here 37 | # t_num = det(np.stack([l0[0] - l1[0], l1[0] - l1[1]], axis=1)) 38 | t_num = (l0[0][0] - l1[0][0]) * (l1[0][1] - l1[1][1]) - ( 39 | l0[0][1] - l1[0][1] 40 | ) * (l1[0][0] - l1[1][0]) 41 | # u_num = -det(np.stack([l0[0] - l0[1], l0[0] - l1[0]], axis=1)) 42 | u_num = -( 43 | (l0[0][0] - l0[1][0]) * (l0[0][1] - l1[0][1]) 44 | - (l0[0][1] - l0[1][1]) * (l0[0][0] - l1[0][0]) 45 | ) 46 | 47 | t0 = t_num / denom 48 | t1 = u_num / denom 49 | 50 | inter = ( 51 | l0[0][0] + t_num * (l0[1][0] - l0[0][0]) / denom, 52 | l0[0][1] + t_num * (l0[1][1] - l0[0][1]) / denom, 53 | ) 54 | 55 | assert inter[0] == l1[0][0] + u_num * (l1[1][0] - l1[0][0]) / denom 56 | assert inter[1] == l1[0][1] + u_num * (l1[1][1] - l1[0][1]) / denom 57 | 58 | return (inter, t0, t1) 59 | 60 | 61 | def line_segment_point_fraction(segment, point): 62 | """Return the fraction the point is of the segment. 63 | 64 | 0 = start 65 | .. 66 | 1 = end 67 | None = not on the same line as segment 68 | """ 69 | if np.all(segment[0] == segment[1]): 70 | return 0 if np.all(segment[0] == point) else None 71 | if segment[0][0] == segment[1][0]: 72 | # Flip x and y 73 | return line_segment_point_fraction( 74 | [(segment[0][1], segment[0][0]), (segment[1][1], segment[1][0])], 75 | (point[1], point[0]), 76 | ) 77 | 78 | f = (point[0] - segment[0][0]) / (segment[1][0] - segment[0][0]) 79 | if np.all(point[1] == segment[0][1] + f * (segment[1][1] - segment[0][1])): 80 | return f 81 | return None 82 | 83 | 84 | def simple_polygon_area(poly): 85 | """Return the area of the simple polygon. 86 | 87 | CCW = positive. 88 | """ 89 | area = 0 90 | 91 | prev = poly[-1] 92 | for vert in poly: 93 | avg_x = (vert[0] + prev[0]) / 2 94 | dy = -(vert[1] - prev[1]) 95 | area += avg_x * dy 96 | prev = vert 97 | 98 | return area 99 | 100 | 101 | def _subsign(a, b): 102 | """A faster way to find 103 | np.sign(a - b) for rationals. 104 | """ 105 | if a > b: 106 | return 1 107 | if a == b: 108 | return 0 109 | return -1 110 | 111 | 112 | def locate_point_polygon_winding_number(poly, point): 113 | """Locate point using winding numbers. 114 | 115 | Returns: 116 | 1 if inside 117 | 0 if on the edge 118 | -1 if outside 119 | 120 | This is currently (2020-11) one of the hottest 121 | functions in geom2d_py. This may change later. 122 | 123 | http://geomalgorithms.com/a03-_inclusion.html 124 | """ 125 | winding = 0 126 | prev = poly[-1] 127 | sprev = _subsign(prev[1], point[1]) 128 | for vert in poly: 129 | try: 130 | # Try stuff just to avoid recalculating 131 | # sprev 132 | svert = _subsign(vert[1], point[1]) 133 | 134 | if sprev * svert == 1: 135 | # Edge completely above or below point 136 | continue 137 | 138 | if np.all(vert == point): 139 | return 0 140 | 141 | # Ignore horizontal unless we are on there 142 | if prev[1] == vert[1]: 143 | if ( 144 | prev[0] < point[0] < vert[0] 145 | or prev[0] > point[0] > vert[0] 146 | ): 147 | return 0 148 | continue 149 | 150 | # Exclude upper vertex 151 | if max(vert[1], prev[1]) == point[1]: 152 | continue 153 | 154 | area = simple_polygon_area([prev, vert, point]) 155 | if area == 0: 156 | # On edge, exactly 157 | return 0 158 | if area > 0: 159 | winding += 1 160 | else: 161 | winding -= 1 162 | finally: 163 | prev = vert 164 | sprev = svert 165 | 166 | return 1 if winding != 0 else -1 167 | 168 | 169 | def edges_iter(poly): 170 | """Iterate over all pairs (poly[i], poly[i + 1]), wrapping""" 171 | n = len(poly) 172 | for i in range(n): 173 | yield (poly[i], poly[(i + 1) % n]) 174 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/scan2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """2D line sweep scanning algorithms 20 | """ 21 | import math 22 | from sortedcontainers import SortedList, SortedDict 23 | 24 | # Setting this to True enables some expensive 25 | # checks used in the tests 26 | _extra_checks = False 27 | 28 | 29 | def all_segment_intersections(segments): 30 | """Find all intersections between the segments. 31 | 32 | Returns iterator over entries (pt, (segment, ...)) 33 | 34 | The segments must be unique. 35 | 36 | If two segments overlap on a line, only the ends of the 37 | overlap create intersection points. 38 | 39 | If given segments that use the fractions.Fraction class 40 | or the gmpy.mpq class or some other exact rational arithmetics. 41 | Due to the way Python works, make sure that all inputs 42 | are of the rational class, even if some coordinates are 43 | representable as integers such as 0, 1. 44 | 45 | The segments can be floating-point in which case the results 46 | will be inexact but should still work (though this case 47 | has not been tested). 48 | 49 | Internals: 50 | 51 | Using the Bentley-Ottmann algorithm, 52 | adapted from Berg et al: Computational Geometry, 2nd edition 53 | 54 | However, instead of the extra complexity of handling 55 | horizontal segments, we simply find an explicit transformation 56 | 57 | y <- y + alpha x 58 | 59 | which leaves the points in general position. 60 | """ 61 | 62 | segments = list(segments) 63 | alpha = _find_general_y_shear(segments) 64 | 65 | tr_segments = [ 66 | tuple([(pt[0], pt[1] + alpha * pt[0]) for pt in segment]) 67 | for segment in segments 68 | ] 69 | origs = {k: v for k, v in zip(tr_segments, segments)} 70 | 71 | for pt, segs in _all_segment_intersections_no_horizontal(tr_segments): 72 | npt = (pt[0], pt[1] - alpha * pt[0]) 73 | assert type(npt[0]) != float 74 | assert type(npt[1]) != float 75 | yield (npt, [origs[seg] for seg in segs]) 76 | 77 | 78 | def _find_general_y_shear(segments): 79 | """Find a transformation so no segments are horizontal. 80 | 81 | Could do something nicer to get less decimals 82 | but for now, just take alpha to be zero if no horizontals 83 | or half of the smallest non-horizontal direction if there are any. 84 | 85 | Just to avoid sign errors, we take the minimum over slopes 86 | in both directions. 87 | """ 88 | had_horizontal = False 89 | max_ratio = None 90 | 91 | for segment in segments: 92 | d = (segment[1][0] - segment[0][0], segment[1][1] - segment[0][1]) 93 | if d[0] != 0: 94 | if d[1] == 0: 95 | had_horizontal = True 96 | else: 97 | ratio = abs(d[1] / d[0]) 98 | if max_ratio is None or ratio < max_ratio: 99 | max_ratio = ratio 100 | if not had_horizontal: 101 | return 0 102 | if max_ratio is None: 103 | return 1 104 | return max_ratio / 2 105 | 106 | 107 | def _at_y_no_horizontal(segment, y): 108 | if y == segment[0][1]: 109 | return segment[0][0] 110 | if y == segment[1][1]: 111 | return segment[1][0] 112 | # TODO cache 113 | dy = segment[1][1] - segment[0][1] 114 | assert dy != 0 115 | x0 = segment[0][0] 116 | dx = segment[1][0] - segment[0][0] 117 | dy_nw = y - segment[0][1] 118 | return x0 + dx * dy_nw / dy 119 | 120 | 121 | class _SweepKey: 122 | """A key for the y line sweep. 123 | 124 | The key takes into account where the segment is inserted 125 | and compares the segmments at and after that point. 126 | 127 | This avoids the complexity of inserting things into a binary 128 | tree with different orders and breaking the encapsulation 129 | between tree balancing and the sweep logic. 130 | 131 | The keys are not totally ordered but the subset that is 132 | in the active data structure at one time is. 133 | """ 134 | 135 | def __init__(self, segment, pt): 136 | # Horizontal is not allowed 137 | assert segment[0][1] != segment[1][1] 138 | self.segment = segment 139 | self.pt = pt 140 | 141 | def __repr__(self): 142 | return f"_SweepKey({self.segment}, {self.pt})" 143 | 144 | def __hash__(self): 145 | return hash((self.segment, self.pt)) 146 | 147 | def __eq__(self, other): 148 | return (self.pt == other.pt) and (self.segment == other.segment) 149 | 150 | def __lt__(self, other): 151 | # We compare at the maximum event y 152 | if other.pt[1] > self.pt[1]: 153 | cmp_y = other.pt[1] 154 | other_x = other.pt[0] 155 | self_x = self.at_y(cmp_y) 156 | else: 157 | cmp_y = self.pt[1] 158 | other_x = other.at_y(cmp_y) 159 | self_x = self.pt[0] 160 | 161 | if self_x < other_x: 162 | return True 163 | if self_x > other_x: 164 | return False 165 | 166 | # Now we know that self_x == other_x, i.e., the lines 167 | # meet at the relevant y. 168 | 169 | # If the points are from the same Y, must compare slightly after; 170 | # if from different Ys, must compare slightly before 171 | # to get results consistent with others 172 | if other.pt[1] == self.pt[1]: 173 | alt_cmp_y = cmp_y + 1 174 | else: 175 | alt_cmp_y = cmp_y - 1 176 | self_next_x = self.at_y(alt_cmp_y) 177 | other_next_x = other.at_y(alt_cmp_y) 178 | 179 | if self_next_x < other_next_x: 180 | return True 181 | if self_next_x > other_next_x: 182 | return False 183 | 184 | if self.segment < other.segment: 185 | return True 186 | 187 | return False 188 | 189 | def at_y(self, y): 190 | return _at_y_no_horizontal(self.segment, y) 191 | 192 | 193 | def _all_segment_intersections_no_horizontal(segments): # noqa 194 | # Must be unique 195 | assert len(set(segments)) == len(segments) 196 | segments = list(segments) 197 | 198 | # Must not be degenerate 199 | for segment in segments: 200 | assert segment[0] != segment[1] 201 | 202 | # Use the convention from the book: sweep on Y axis 203 | def event_key(pt): 204 | return (pt[1], pt[0]) 205 | 206 | # From point to list of segments 207 | event_queue = SortedDict(event_key) 208 | 209 | def add_event(pt, segment_key=None): 210 | if pt not in event_queue: 211 | event_queue[pt] = [] 212 | if segment_key is not None: 213 | event_queue[pt].append(segment_key) 214 | 215 | for i, segment in enumerate(segments): 216 | if event_key(segment[0]) < event_key(segment[1]): 217 | add_event(segment[0], _SweepKey(segment, segment[0])) 218 | add_event(segment[1], None) 219 | else: 220 | add_event(segment[0], None) 221 | add_event(segment[1], _SweepKey(segment, segment[1])) 222 | 223 | active = SortedList() 224 | 225 | y = -math.inf 226 | 227 | while len(event_queue) > 0: 228 | v = event_queue.popitem(0) 229 | pt, segstarts = v 230 | 231 | # Can't be > since while there are no horizontal segments, 232 | # there can still be points in horizontal relation to one another 233 | assert pt[1] >= y 234 | y = pt[1] 235 | 236 | # Find all segments within the event point 237 | 238 | fake_segment = ((pt[0], pt[1]), (pt[0], pt[1] + 1)) 239 | fake_key = _SweepKey(fake_segment, pt) 240 | 241 | touches = [] 242 | 243 | # The next lower / higher keys, respectively, to enter new events for 244 | neighbours = [] 245 | 246 | if _extra_checks: 247 | _assert_fully_sorted(list(active), y) 248 | # Iterate on both sides 249 | for it in ( 250 | active.irange( 251 | None, fake_key, inclusive=(True, True), reverse=True 252 | ), 253 | active.irange(fake_key, None, inclusive=(False, True)), 254 | ): 255 | neighbour = None 256 | for sweep_key in it: 257 | if sweep_key.at_y(y) != pt[0]: 258 | neighbour = sweep_key 259 | break 260 | touches.append(sweep_key) 261 | neighbours.append(neighbour) 262 | 263 | # Remove the old sweep keys 264 | for touch in touches: 265 | active.remove(touch) 266 | 267 | segments_at_pt = [ 268 | sweep_key.segment for sweep_key in touches + segstarts 269 | ] 270 | if len(segments_at_pt) > 1: 271 | yield (pt, tuple(segments_at_pt)) 272 | 273 | # Create new _SweepKeys, automatically sorts 274 | # according to order after point 275 | sweep_keys = [] 276 | for segment in segments_at_pt: 277 | # Is this segment still relevant? 278 | if max(segment[0][1], segment[1][1]) <= pt[1]: 279 | continue 280 | sweep_keys.append(_SweepKey(segment, pt)) 281 | 282 | sweep_keys = list(sorted(sweep_keys)) 283 | 284 | # Add new events for neighbours 285 | if len(sweep_keys) == 0: 286 | # If we just removed stuff, the neighbours might now meet... 287 | if neighbours[0] is not None and neighbours[1] is not None: 288 | ipt = _nonparallel_intersection_point( 289 | neighbours[0].segment, neighbours[1].segment 290 | ) 291 | if ipt and ipt[1] > pt[1]: 292 | add_event(ipt) 293 | 294 | continue 295 | 296 | if neighbours[0] is not None: 297 | ipt = _nonparallel_intersection_point( 298 | sweep_keys[0].segment, neighbours[0].segment 299 | ) 300 | # hyp.note(fstr('IPTL', ipt, pt)) 301 | if ipt and ipt[1] > pt[1]: 302 | add_event(ipt) 303 | 304 | if neighbours[1] is not None: 305 | ipt = _nonparallel_intersection_point( 306 | sweep_keys[-1].segment, neighbours[1].segment 307 | ) 308 | # hyp.note(fstr('IPTR', ipt, pt)) 309 | if ipt and ipt[1] > pt[1]: 310 | add_event(ipt) 311 | 312 | # Add them in and continue 313 | for sweep_key in sweep_keys: 314 | active.add(sweep_key) 315 | 316 | 317 | def _det(va, vb): 318 | return va[0] * vb[1] - va[1] * vb[0] 319 | 320 | 321 | def _nonparallel_intersection_point(a, b): 322 | """ 323 | Returns the intersection point or None 324 | if the segments are parallel or do not intersect 325 | """ 326 | da = (a[1][0] - a[0][0], a[1][1] - a[0][1]) 327 | db = (b[1][0] - b[0][0], b[1][1] - b[0][1]) 328 | 329 | denom = _det(da, db) 330 | if denom == 0: 331 | return None 332 | 333 | dab0 = (a[0][0] - b[0][0], a[0][1] - b[0][1]) 334 | 335 | t_num = _det(db, dab0) 336 | u_num = _det(da, dab0) 337 | 338 | if denom < 0: 339 | t_num = -t_num 340 | u_num = -u_num 341 | denom = -denom 342 | 343 | if 0 <= t_num <= denom and 0 <= u_num <= denom: 344 | ta = t_num / denom 345 | inter = (a[0][0] + ta * da[0], a[0][1] + ta * da[1]) 346 | return inter 347 | 348 | return None 349 | 350 | 351 | def _assert_fully_sorted(alist, y): 352 | """Check that the active list is fully ordered 353 | """ 354 | for i in range(len(alist)): 355 | ys = tuple(sorted((alist[i].segment[0][1], alist[i].segment[1][1]))) 356 | assert ys[0] <= y <= ys[1], (ys, y, alist[i]) 357 | 358 | for i in range(len(alist)): 359 | 360 | assert alist[i] == alist[i] 361 | 362 | if i > 1: 363 | assert alist[i - 1] < alist[i] 364 | 365 | # Check that all comparisons are correct 366 | for j in range(i): 367 | cmps = ( 368 | alist[i] != alist[j], 369 | alist[i] == alist[j], 370 | alist[i] > alist[j], 371 | alist[i] < alist[j], 372 | alist[j] != alist[i], 373 | alist[j] == alist[i], 374 | alist[j] > alist[i], 375 | alist[j] < alist[i], 376 | ) 377 | assert cmps == ( 378 | True, 379 | False, 380 | True, 381 | False, 382 | True, 383 | False, 384 | False, 385 | True, 386 | ), (i, j, cmps) 387 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/segment_tree.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | 20 | class SegmentTree1D_Slow: 21 | """Slow reference implementation""" 22 | 23 | def __init__(self, segments): 24 | self.segments = segments 25 | 26 | def stab(self, v): 27 | for low, high, id in self.segments: 28 | if low <= v and high >= v: 29 | yield low, high, id 30 | 31 | 32 | class SegmentTree1D: 33 | """Non-incremental 1D segment tree. 34 | 35 | segments -- iterator with elements 36 | (min, max, obj) 37 | """ 38 | 39 | class Node: 40 | def __init__(self, left, right): 41 | self.min = left.min 42 | self.closed_low = left.closed_low 43 | self.max = right.max 44 | self.closed_high = right.closed_high 45 | assert left.max == right.min, (left.__dict__, right.__dict__) 46 | assert left.closed_high == (not right.closed_low) 47 | self.left = left 48 | self.right = right 49 | self.cut = left.max 50 | self.segments = [] 51 | 52 | def is_inside(self, pt, side=0): 53 | if side <= 0: 54 | if self.closed_high: 55 | if pt > self.max: 56 | return False 57 | else: 58 | if pt >= self.max: 59 | return False 60 | if side >= 0: 61 | if self.closed_low: 62 | if pt < self.min: 63 | return False 64 | else: 65 | if pt <= self.min: 66 | return False 67 | return True 68 | 69 | def add_segment(self, low, high, id): 70 | if not self.is_inside(low, side=-1): 71 | return 72 | if not self.is_inside(high, side=1): 73 | return 74 | 75 | if low <= self.min and high >= self.max: 76 | self.segments.append((low, high, id)) 77 | else: 78 | self.left.add_segment(low, high, id) 79 | self.right.add_segment(low, high, id) 80 | 81 | def stab(self, value): 82 | if self.min <= value <= self.max: 83 | yield from iter(self.segments) 84 | if value < self.cut or ( 85 | value == self.cut and self.left.closed_high 86 | ): 87 | yield from self.left.stab(value) 88 | else: 89 | yield from self.right.stab(value) 90 | 91 | def __repr__(self): 92 | def c(closed): 93 | return "C" if closed else "V" 94 | 95 | return ( 96 | f"N({self.min}{c(self.closed_low)}, {self.cut}, " 97 | f"{self.max}{c(self.closed_high)}, {self.segments}, " 98 | f"{self.left}, {self.right})" 99 | ) 100 | 101 | class PointLeaf: 102 | def __init__(self, value): 103 | self.min = value 104 | self.max = value 105 | self.closed_low = True 106 | self.closed_high = True 107 | self.at = [] 108 | 109 | def intersects_closed(self, x0, x1): 110 | return x0 <= self.max and x1 >= self.min 111 | 112 | def add_segment(self, low, high, id): 113 | if self.intersects_closed(low, high): 114 | self.at.append((low, high, id)) 115 | 116 | def stab(self, value): 117 | if value == self.min: 118 | yield from iter(self.at) 119 | 120 | def __repr__(self): 121 | return f"PL({self.min}C, {self.max}C, {self.at})" 122 | 123 | class RangeLeaf: 124 | def __init__(self, low, high): 125 | self.min = low 126 | self.max = high 127 | self.closed_low = False 128 | self.closed_high = False 129 | self.at = [] 130 | 131 | def intersects_closed(self, x0, x1): 132 | return x0 < self.max and x1 > self.min 133 | 134 | def add_segment(self, low, high, id): 135 | if self.intersects_closed(low, high): 136 | self.at.append((low, high, id)) 137 | 138 | def stab(self, value): 139 | # Get both when value == self.value 140 | if value > self.min and value < self.max: 141 | yield from self.at 142 | 143 | def __repr__(self): 144 | return f"RL({self.min}V, {self.max}V, {self.at})" 145 | 146 | def __init__(self, segments): 147 | endpoints = [] 148 | for v0, v1, _ in segments: 149 | assert v1 >= v0 150 | endpoints.extend((v0, v1)) 151 | endpoints = list(sorted(set(endpoints))) 152 | 153 | if len(endpoints) == 0: 154 | endpoints = [0, 1] 155 | 156 | nodes = [] 157 | for i in range(len(endpoints)): 158 | if i > 0: 159 | nodes.append(self.RangeLeaf(endpoints[i - 1], endpoints[i])) 160 | nodes.append(self.PointLeaf(endpoints[i])) 161 | 162 | while len(nodes) > 1: 163 | new_nodes = [] 164 | for i in range(0, len(nodes), 2): 165 | combo = nodes[i : i + 2] 166 | if len(combo) == 2: 167 | combo = [self.Node(*combo)] 168 | new_nodes.extend(combo) 169 | nodes = new_nodes 170 | 171 | self.root = nodes[0] 172 | 173 | for segment in segments: 174 | self.root.add_segment(*segment) 175 | 176 | # print(self.root) 177 | 178 | assert self.root.min == min(endpoints) 179 | assert self.root.max == max(endpoints), (self.root.max, max(endpoints)) 180 | 181 | def stab(self, value): 182 | for low, high, id in self.root.stab(value): 183 | assert low <= value, (value, low, high, id) 184 | assert high >= value, (value, low, high, id) 185 | yield low, high, id 186 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_cell_merger.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from ..cell_merger import CellMergerSlow, canonicalize_cell 20 | import numpy as np 21 | import hypothesis as hyp 22 | import hypothesis.strategies as hys 23 | 24 | from ....plytypes import Fraction, totuple 25 | 26 | F = Fraction 27 | 28 | 29 | hyp.settings.register_profile( 30 | "verbose", verbosity=hyp.Verbosity.verbose, max_examples=1000 31 | ) 32 | hyp.settings.load_profile("verbose") 33 | 34 | SQUARE = np.array([[0, 0], [0, 1], [1, 1], [1, 0],]) 35 | 36 | N = 3 37 | SQUARES = {(x, y): SQUARE + [x, y] for x in range(N) for y in range(N)} 38 | 39 | SQUARES_KEYS = list(sorted(SQUARES.keys())) 40 | 41 | 42 | @hyp.settings(deadline=30000) 43 | @hyp.given( 44 | hys.lists(min_size=2, max_size=20, elements=hys.permutations(SQUARES_KEYS)) 45 | ) 46 | # elements=hys.sampled_from(SQUARES_KEYS), unique=True)))) 47 | def test_cell_merger(perms): 48 | canonical = None 49 | for perm in perms: 50 | m = CellMergerSlow() 51 | for k in perm: 52 | cell = totuple(SQUARES[k]) 53 | m.add_cell(cell) 54 | hyp.note(("add", cell)) 55 | 56 | if False: 57 | origs_used = set() 58 | for k, v in m.get_cells_originals().items(): 59 | hyp.note(k) 60 | for orig in v: 61 | hyp.note(" %s" % (orig,)) 62 | vset = set(v) 63 | assert len(origs_used & vset) == 0 64 | origs_used |= vset 65 | 66 | cells = list(sorted([canonicalize_cell(c) for c in m.get_cells()])) 67 | 68 | if canonical is None: 69 | canonical = cells 70 | 71 | assert cells == canonical 72 | 73 | assert len(cells) == 1 74 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_frangle2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import pytest 20 | import hypothesis as hyp 21 | import hypothesis.stateful 22 | import hypothesis.strategies as hys 23 | import numpy as np 24 | 25 | from ....plytypes import F, fstr 26 | from ..frangle2d import vector_frangle, frangle_unit_square_vector 27 | 28 | 29 | @hys.composite 30 | def ply_fractions(draw): 31 | return F(draw(hys.fractions(max_denominator=101))) 32 | 33 | 34 | @hys.composite 35 | def points(draw): 36 | return draw(hys.tuples(ply_fractions(), ply_fractions())) 37 | 38 | 39 | def P(a, b): 40 | return (F(a), F(b)) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "fx,fy,o", 45 | [ 46 | (4, 0, 0), 47 | (4, -2, 1 / 2), 48 | (4, -4, 1), 49 | (2, -4, 3 / 2), 50 | (0, -4, 2), 51 | (-2, -4, 5 / 2), 52 | (-4, -4, 3), 53 | (-4, -2, 7 / 2), 54 | (-4, 0, 4), 55 | (-4, 2, 9 / 2), 56 | (-4, 4, 5), 57 | (-2, 4, 11 / 2), 58 | (0, 4, 6), 59 | (2, 4, 13 / 2), 60 | (4, 4, 7), 61 | (4, 2, 15 / 2), 62 | ], 63 | ) 64 | def test_vector_frangle(fx, fy, o): 65 | assert vector_frangle((F(fx, 16), F(fy, 16), None)) == o 66 | assert ( 67 | vector_frangle( 68 | frangle_unit_square_vector( 69 | vector_frangle((F(fx, 16), F(fy, 16), None)) 70 | ) 71 | ) 72 | == o 73 | ) 74 | 75 | 76 | @hyp.given(points()) 77 | @hyp.example(P(1, 0)) 78 | def test_vector_frangle_2(pt): 79 | pt = np.array(pt, object) 80 | hyp.assume(sum(pt ** 2) > 0) 81 | 82 | frangle = vector_frangle(pt) 83 | usq = frangle_unit_square_vector(frangle) 84 | 85 | frangle2 = vector_frangle(usq) 86 | 87 | hyp.note(fstr(pt, frangle, usq, frangle2)) 88 | 89 | assert frangle == frangle2 90 | 91 | assert usq[0] * pt[1] == usq[1] * pt[0] 92 | assert (usq[0] == 0) == (pt[0] == 0) 93 | assert (usq[1] == 0) == (pt[1] == 0) 94 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_geom2d_simple_holes_stress.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2021 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | # Randomized tests for Geom2D 20 | 21 | import hypothesis as hyp 22 | import hypothesis.strategies as hys 23 | 24 | from ....plytypes import F 25 | 26 | from ..geom2d_simple_holes import Geom2D_Py_Simple_Holes as Geom2D 27 | 28 | from .util import tr, random_transform_matrices 29 | 30 | ######### 31 | # 32 | # Merge squares 33 | # 34 | # Uses squares on a square grid to run a set of operations sequentially 35 | # to an accumulator that starts empty. 36 | # 37 | # An operation looks like 38 | # 39 | # ('and', [(1, 2), (3, 4)]) 40 | # 41 | # which means that the squares at (1, 2) and (3, 4) should be 'or'ed 42 | # together and then the accumulator should be 'and'ed with the result. 43 | 44 | N = 8 45 | 46 | squares = [ 47 | [Geom2D.rectangle(x, y, x + 1, y + 1) for y in range(N)] for x in range(N) 48 | ] 49 | square_idxs = [(x, y) for x in range(N) for y in range(N)] 50 | 51 | 52 | @hyp.settings(deadline=30000) # noqa: C901 53 | @hyp.given( 54 | hys.lists( 55 | elements=hys.tuples( 56 | hys.sampled_from(["and", "or", "sub"]), 57 | hys.lists(elements=hys.sampled_from(square_idxs)), 58 | ) 59 | ), 60 | random_transform_matrices(), 61 | ) 62 | def test_merge_squares(ops, transform): # noqa: C901 63 | # The following two are two different ways of representing 64 | # the same thing: accumulator as Geom2D and manual as 65 | # a dict of square coordinate positions. 66 | # 67 | # Later we assert that they stay the same. 68 | accumulator = Geom2D.empty() 69 | manual = {k: 0 for k in square_idxs} 70 | 71 | for op, args in ops: 72 | if op == "and": 73 | new_manual = {k: 0 for k in manual.keys()} 74 | for x, y in args: 75 | new_manual[x, y] = manual[x, y] 76 | manual = new_manual 77 | 78 | operand = Geom2D.empty() 79 | 80 | hyp.note(op) 81 | 82 | for x, y in sorted(args): 83 | hyp.note((x, y)) 84 | square = squares[x][y].transformed_with( 85 | lambda coords: tr(transform, coords) 86 | ) 87 | operand = operand | square 88 | if op == "or": 89 | manual[x, y] = 1 90 | elif op == "sub": 91 | manual[x, y] = 0 92 | 93 | hyp.note("go") 94 | if op == "and": 95 | accumulator = accumulator & operand 96 | elif op == "or": 97 | accumulator = accumulator | operand 98 | elif op == "sub": 99 | accumulator = accumulator - operand 100 | 101 | half = F(1, 2) 102 | for x, y in square_idxs: 103 | pt = tr(transform, (x + half, y + half)) 104 | loc = accumulator.locate(pt) 105 | assert loc != 0 106 | assert (loc == 1) == (manual[x, y] == 1), manual 107 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_pmr_quadtree.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | 20 | from .. import pmr_quadtree 21 | 22 | 23 | # XXX Write better tests 24 | def test_segment_pmr_quadtree(): 25 | segs = [((0, 0), (1, 1))] 26 | 27 | pqt = pmr_quadtree.SegmentPMRQuadTree(items=segs) 28 | 29 | assert list(pqt.find(((0, 1), (1, 0)))) == segs 30 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_primitive2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import hypothesis as hyp 20 | import hypothesis.strategies as hys 21 | import numpy as np 22 | 23 | from ....plytypes import F 24 | 25 | from ..primitive2d import segment_segment_general_intersection 26 | 27 | from .util import tr, ply_fractions, det22, random_transform_matrices 28 | 29 | 30 | def P(a, b): 31 | return (F(a), F(b)) 32 | 33 | 34 | ########### 35 | # 36 | # Test the fast general-position routine 37 | 38 | 39 | @hyp.given( 40 | hys.lists( 41 | min_size=4, 42 | max_size=4, 43 | elements=hys.tuples(ply_fractions(), ply_fractions(),), 44 | ), 45 | random_transform_matrices(), 46 | ) 47 | # Hypothesis doesn't seem to find this, hmm... 48 | @hyp.example( 49 | [P(0, 0), P(0, 1), P(1, 0), P(1, 1)], np.array([[1, 0, 0], [1, 1, 0]]) 50 | ) 51 | def test_segment_segment_general_intersection(pts, transform): 52 | def same_intersection(a, b, transform, swap, revs): 53 | (apt, at0, at1) = a 54 | (bpt, bt0, bt1) = b 55 | 56 | if revs[0]: 57 | bt0 = 1 - bt0 58 | if revs[1]: 59 | bt1 = 1 - bt1 60 | 61 | if swap: 62 | (bt0, bt1) = (bt1, bt0) 63 | 64 | apt = tr(transform, apt) 65 | 66 | if not np.all(apt == bpt): 67 | return (False, ("pt", a, b)) 68 | 69 | if not (at0 == bt0): 70 | return (False, ("t0", at0, bt0, a, b)) 71 | 72 | if not (at1 == bt1): 73 | return (False, ("t1", at1, bt1, a, b)) 74 | 75 | return (True, "") 76 | 77 | def lrev(lst): 78 | return list(reversed(lst)) 79 | 80 | op = np.array(pts, object) 81 | hyp.assume(det22(np.stack([op[3] - op[2], op[1] - op[0]])) != 0) 82 | 83 | def assrt(v, msg): 84 | assert v, msg 85 | 86 | assrt( 87 | *same_intersection( 88 | segment_segment_general_intersection(pts[0:2], pts[2:4]), 89 | segment_segment_general_intersection(pts[2:4], pts[0:2]), 90 | None, 91 | True, 92 | [False, False], 93 | ) 94 | ) 95 | 96 | assrt( 97 | *same_intersection( 98 | segment_segment_general_intersection(pts[0:2], pts[2:4]), 99 | segment_segment_general_intersection(lrev(pts[2:4]), pts[0:2]), 100 | None, 101 | True, 102 | [True, False], 103 | ) 104 | ) 105 | 106 | assrt( 107 | *same_intersection( 108 | segment_segment_general_intersection(pts[0:2], pts[2:4]), 109 | segment_segment_general_intersection(pts[2:4], lrev(pts[0:2])), 110 | None, 111 | True, 112 | [False, True], 113 | ) 114 | ) 115 | 116 | assrt( 117 | *same_intersection( 118 | segment_segment_general_intersection(pts[0:2], pts[2:4]), 119 | segment_segment_general_intersection( 120 | lrev(pts[2:4]), lrev(pts[0:2]) 121 | ), 122 | None, 123 | True, 124 | [True, True], 125 | ) 126 | ) 127 | 128 | tpts = pts @ transform[:, 0:2].T + transform[:, 2] 129 | 130 | assrt( 131 | *same_intersection( 132 | segment_segment_general_intersection(pts[0:2], pts[2:4]), 133 | segment_segment_general_intersection(tpts[0:2], tpts[2:4]), 134 | transform, 135 | False, 136 | [False, False], 137 | ) 138 | ) 139 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_scan2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import hypothesis as hyp 20 | import hypothesis.strategies as hys 21 | 22 | # from fractions import Fraction as F 23 | from gmpy2 import mpq as F 24 | 25 | from .. import scan2d 26 | 27 | # Run tests using the default python fraction class to avoid extra 28 | # dependencies. Can use the gmpy2 class equally well, the main 29 | # code is agnostic and just requires exact numbers of some type. 30 | 31 | ###### 32 | # Vector utilities 33 | 34 | 35 | def S(a, b): 36 | """Convert to segment using fractions. 37 | """ 38 | return ((F(a[0]), F(a[1])), (F(b[0]), F(b[1]))) 39 | 40 | 41 | def diffv(a, b): 42 | return ( 43 | a[0] - b[0], 44 | a[1] - b[1], 45 | ) 46 | 47 | 48 | def lerpv(a, b, f): 49 | return ( 50 | a[0] + (b[0] - a[0]) * f, 51 | a[1] + (b[1] - a[1]) * f, 52 | ) 53 | 54 | 55 | def dotv(a, b): 56 | return a[0] * b[0] + a[1] * b[1] 57 | 58 | 59 | def parallel_intersection_points(a, b): 60 | da = (a[1][0] - a[0][0], a[1][1] - a[0][1]) 61 | db = (b[1][0] - b[0][0], b[1][1] - b[0][1]) 62 | 63 | denom = scan2d._det(da, db) 64 | if denom != 0: 65 | # Not parallel 66 | return [] 67 | 68 | rda = (da[1], -da[0]) 69 | if dotv(rda, a[0]) != dotv(rda, b[0]): 70 | # Parallel but not collinear 71 | return [] 72 | 73 | # Sort the points. Ok also when horizontal. 74 | spt = list(sorted([(a[0], 0), (a[1], 0), (b[0], 1), (b[1], 1),])) 75 | 76 | if spt[1][0] == spt[2][0]: 77 | # One-point overlap at middle 78 | return [spt[1][0]] 79 | 80 | if spt[0][1] == spt[1][1]: 81 | # No overlap (lowest two points from same segment) 82 | return [] 83 | 84 | # The middle is automatically overlap 85 | return [spt[1][1], spt[2][1]] 86 | 87 | 88 | def affine_transform(affine_mat, pt): 89 | return ( 90 | affine_mat[0][0] * pt[0] + affine_mat[0][1] * pt[1] + affine_mat[0][2], 91 | affine_mat[1][0] * pt[0] + affine_mat[1][1] * pt[1] + affine_mat[1][2], 92 | ) 93 | 94 | 95 | ###### 96 | # Hypothesis strategies 97 | 98 | 99 | @hys.composite 100 | def fractions(draw, *args, **kwargs): 101 | return F(draw(hys.fractions(*args, **kwargs))) 102 | 103 | 104 | @hys.composite 105 | def points(draw): 106 | return draw(hys.tuples(fractions(), fractions())) 107 | 108 | 109 | @hys.composite 110 | def proper_segments(draw): 111 | v0 = draw(points()) 112 | v1 = draw(points()) 113 | # v1 = (v1[0], v1[1] + 1) 114 | hyp.assume(v0 != v1) 115 | return (v0, v1) 116 | 117 | 118 | @hys.composite 119 | def proper_nonhorizontal_segments(draw): 120 | seg = draw(proper_segments()) 121 | hyp.assume(seg[0][1] != seg[1][1]) 122 | return seg 123 | 124 | 125 | @hys.composite 126 | def proper_affine_matrices(draw): 127 | """Return non-singular affine matrices.""" 128 | m = [[draw(fractions()) for i in range(3)] for j in range(2)] 129 | 130 | hyp.assume(scan2d._det(m[0][0:2], m[1][0:2]) != 0) 131 | return m 132 | 133 | 134 | ####### 135 | # Tests for nonparallel_intersection 136 | 137 | 138 | @hyp.given( 139 | pt=points(), 140 | pta=points(), 141 | ptb=points(), 142 | fa=fractions(min_value=-1, max_value=0.99), 143 | fb=fractions(min_value=-1, max_value=0.99), 144 | ) 145 | def test_nonparallel_intersection_point_nonparallel(pt, pta, ptb, fa, fb): 146 | # Generate cases from an intersection point 147 | # fa and fb describe the other end of the lines from pta and ptb, 148 | # which do intersect if 149 | hyp.assume(pt != pta) 150 | hyp.assume(pt != ptb) 151 | 152 | det = scan2d._det(diffv(pta, pt), diffv(ptb, pt)) 153 | 154 | if fa <= 0 and fb <= 0 and det != 0: 155 | desired = pt 156 | else: 157 | desired = None 158 | 159 | # Determine the other ends 160 | 161 | pta0 = lerpv(pt, pta, fa) 162 | ptb0 = lerpv(pt, ptb, fb) 163 | 164 | # Assert all combinations 165 | 166 | assert ( 167 | scan2d._nonparallel_intersection_point((pta0, pta), (ptb0, ptb)) 168 | == desired 169 | ) 170 | assert ( 171 | scan2d._nonparallel_intersection_point((pta, pta0), (ptb0, ptb)) 172 | == desired 173 | ) 174 | assert ( 175 | scan2d._nonparallel_intersection_point((pta0, pta), (ptb, ptb0)) 176 | == desired 177 | ) 178 | assert ( 179 | scan2d._nonparallel_intersection_point((pta, pta0), (ptb, ptb0)) 180 | == desired 181 | ) 182 | 183 | 184 | @hyp.given( 185 | pt=points(), pt2=points(), off=points(), f=fractions(), 186 | ) 187 | def test_nonparallel_intersection_point_parallel(pt, pt2, off, f): 188 | hyp.assume(pt != pt2) 189 | hyp.assume(f != 0) 190 | 191 | # This yields two parallel segments 192 | 193 | seg1 = (pt, pt2) 194 | 195 | seg2 = (diffv(pt, off), diffv(pt2, off)) 196 | seg2 = (seg2[0], lerpv(seg2[0], seg2[1], f)) 197 | 198 | assert scan2d._nonparallel_intersection_point(seg1, seg2) is None 199 | 200 | 201 | ###### 202 | # The main event: tests for all_segment_intersections 203 | 204 | 205 | def _normalize_result(intersections): 206 | return list( 207 | sorted([(pt, tuple(sorted(segs))) for pt, segs in intersections]) 208 | ) 209 | 210 | 211 | def asi(segments): 212 | """Normalize inputs and outputs""" 213 | return _normalize_result( 214 | scan2d.all_segment_intersections([S(*s) for s in segments]) 215 | ) 216 | 217 | 218 | def test_all_segment_intersections_simple(): 219 | """Test really basic things""" 220 | 221 | assert asi([]) == [] 222 | 223 | sa = S((0, 0), (1, 0)) 224 | sb = S((0, 0), (0, 1)) 225 | 226 | assert asi([sa, sb]) == [((F(0), F(0)), (sb, sa))] 227 | 228 | sa = S((-1, 0), (1, 0)) 229 | sb = S((0, -1), (0, 1)) 230 | 231 | assert asi([sa, sb]) == [((F(0), F(0)), (sa, sb))] 232 | 233 | 234 | @hyp.settings(deadline=25000, suppress_health_check=[hyp.HealthCheck.too_slow]) 235 | @hyp.given( 236 | hys.lists( 237 | elements=proper_nonhorizontal_segments(), unique=True, max_size=30, 238 | ), 239 | proper_affine_matrices(), 240 | # hys.lists( 241 | # elements=proper_segments(), 242 | # unique=True, 243 | # max_size=0, # XXX New segment endpoints 244 | # # in the middle of a collinear segment overlap 245 | # # must be resolved 246 | # ), 247 | ) 248 | # , distractors): 249 | def test_all_segment_intersections_transform(segments, transform): 250 | """Verify that the intersections between a bunch of segments remain the same 251 | under transforms and adding extra segments does not remove points""" 252 | distractors = [] 253 | 254 | def transform_segment(segment): 255 | return ( 256 | affine_transform(transform, segment[0]), 257 | affine_transform(transform, segment[1]), 258 | ) 259 | 260 | tr_segments = set([transform_segment(segment) for segment in segments]) 261 | 262 | distracted_tr_segments = list(tr_segments | set(distractors)) 263 | 264 | intersections = asi(segments) 265 | 266 | distracted_tr_intersections = asi(distracted_tr_segments) 267 | 268 | hyp.target( 269 | float(sum([len(segs) for pt, segs in distracted_tr_intersections])) 270 | ) 271 | 272 | manually_tr_intersections = _normalize_result( 273 | [ 274 | ( 275 | affine_transform(transform, pt), 276 | [transform_segment(segment) for segment in inter_segments], 277 | ) 278 | for pt, inter_segments in intersections 279 | ] 280 | ) 281 | 282 | # Filter tr_intersections 283 | # Fix to use walrus in python 3.8... 284 | tr_intersections = _normalize_result( 285 | [ 286 | (pt, tr_segments & set(inter_segments)) 287 | for pt, inter_segments in distracted_tr_intersections 288 | if len(tr_segments & set(inter_segments)) >= 2 289 | ] 290 | ) 291 | 292 | # return locals() 293 | 294 | assert tr_intersections == manually_tr_intersections 295 | 296 | 297 | # @hyp.given( 298 | # hys.lists( 299 | # elements=hys.tuples( 300 | # proper_nonhorizontal_segments(), 301 | # points()))) 302 | # def test_sweepkey_order(seg_pts): 303 | # # Check that the order is total 304 | # sweepkeys = [scan2d._SweepKey(seg, pt) for seg, pt in seg_pts] 305 | # 306 | # def cmp(a, b): 307 | # eq = (a == b) 308 | # ne = (a != b) 309 | # lt = (a < b) 310 | # gt = (b < a) 311 | # assert eq != ne 312 | # if eq: 313 | # assert not lt 314 | # assert not gt 315 | # if ne: 316 | # assert lt != gt 317 | # if lt: return -1 318 | # if gt: return 1 319 | # return 0 320 | # 321 | # # Matrix of comparison results 322 | # cmps = [[cmp(ka, kb) for kb in sweepkeys] for ka in sweepkeys] 323 | # 324 | # 325 | # 326 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/test_segment_tree.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import hypothesis as hyp 20 | import hypothesis.stateful 21 | import hypothesis.strategies as hys 22 | 23 | from ..segment_tree import SegmentTree1D, SegmentTree1D_Slow 24 | 25 | 26 | @hyp.given( 27 | hys.lists( 28 | elements=hys.tuples( 29 | # hys.fractions(), 30 | # hys.fractions(), 31 | hys.integers(), 32 | hys.integers(), 33 | ) 34 | ), 35 | hys.lists(elements=hys.fractions()), 36 | ) 37 | def test_segment_tree_1d(segments, points): 38 | i = [-1] 39 | 40 | def mkid(): 41 | i[0] += 1 42 | return f"s{i[0]}" 43 | 44 | segments = [tuple(sorted((s[0], s[1]))) + (mkid(),) for s in segments] 45 | 46 | st_slow = SegmentTree1D_Slow(segments) 47 | st = SegmentTree1D(segments) 48 | 49 | def pts(): 50 | yield from iter(points) 51 | for low, high, id in segments: 52 | yield low 53 | yield high 54 | 55 | for pt in pts(): 56 | v0 = list(st_slow.stab(pt)) 57 | v1 = list(st.stab(pt)) 58 | 59 | assert list(sorted(v0)) == list(sorted(v1)), (pt, str(st.root)) 60 | -------------------------------------------------------------------------------- /plycutter/geometry/impl2d/tests/util.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2021 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | # Utilities for tests 20 | 21 | import hypothesis as hyp 22 | import hypothesis.strategies as hys 23 | import numpy as np 24 | 25 | from ....plytypes import F 26 | 27 | 28 | def tr(transform, points): 29 | """Transform the 2D points using the (2, 3) matrix""" 30 | if transform is None: 31 | return points 32 | # return transform[:, 0:2] @ points + transform[:, 2] 33 | return points @ transform[:, 0:2].T + transform[:, 2] 34 | 35 | 36 | @hys.composite 37 | def ply_fractions(draw): 38 | """Draw fractions suitable for use with geometry. 39 | 40 | Keeping the max_denominator smallish to keep runtime low.""" 41 | return F(draw(hys.fractions(max_denominator=101))) 42 | 43 | 44 | def det22(m): 45 | """Determinant of 2x2 matrix""" 46 | return m[0, 0] * m[1, 1] - m[0, 1] * m[1, 0] 47 | 48 | 49 | @hys.composite 50 | def random_transform_matrices(draw): 51 | """Random non-singular transform matrices""" 52 | c = [draw(ply_fractions()) for _ in range(6)] 53 | 54 | m = np.reshape(np.array(c, dtype=object), (2, 3)) 55 | det = det22(m) 56 | if det == 0: 57 | m[0, 0] += 1 58 | m[1, 1] += 1 59 | det = det22(m) 60 | hyp.assume(det != 0) 61 | 62 | return m 63 | -------------------------------------------------------------------------------- /plycutter/geometry/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/geometry/tests/test_aabb.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from ..aabb import AABB 20 | import hypothesis as hyp 21 | import hypothesis.stateful 22 | import hypothesis.strategies as hys 23 | 24 | from ...plytypes import Fraction 25 | F = Fraction 26 | 27 | 28 | @hys.composite 29 | def ply_fractions(draw): 30 | return F(draw(hys.fractions())) 31 | 32 | 33 | @hys.composite 34 | def points(draw): 35 | return draw(hys.tuples(ply_fractions(), ply_fractions())) 36 | 37 | 38 | @hyp.given( 39 | hys.lists(elements=points(), min_size=1, max_size=10)) 40 | def test_aabb(pts): 41 | aabb = AABB() 42 | for pt in pts: 43 | assert not aabb.contains(pt) 44 | for pt in pts: 45 | aabb.include_point(pt) 46 | for pt in pts: 47 | assert aabb.contains(pt) 48 | xs = [pt[0] for pt in pts] 49 | ys = [pt[1] for pt in pts] 50 | assert not aabb.contains((xs[0], max(ys) + 1)) 51 | assert not aabb.contains((max(xs) + 1, ys[0])) 52 | assert not aabb.contains((xs[0], min(ys) - 1)) 53 | assert not aabb.contains((min(xs) - 1, ys[0])) 54 | 55 | 56 | @hyp.given( 57 | hys.lists(elements=points(), min_size=1, max_size=10), 58 | hys.lists(elements=points(), min_size=1, max_size=10), 59 | hys.lists(elements=points(), min_size=1, max_size=20), 60 | ) 61 | def test_aabb_intersection(a_pts, b_pts, test_pts): 62 | a = AABB() 63 | b = AABB() 64 | for pt in a_pts: 65 | a.include_point(pt) 66 | assert not b.intersects_aabb(a) 67 | assert not a.intersects_aabb(b) 68 | for pt in b_pts: 69 | b.include_point(pt) 70 | 71 | assert a.intersects_aabb(a) 72 | assert b.intersects_aabb(b) 73 | 74 | for pt in test_pts: 75 | if a.contains(pt) and b.contains(pt): 76 | assert a.intersects_aabb(b) 77 | assert b.intersects_aabb(a) 78 | 79 | 80 | @hyp.given( 81 | hys.lists(elements=points(), min_size=1, max_size=10), 82 | hys.floats(min_value=0, max_value=1), 83 | hys.floats(min_value=0, max_value=1), 84 | points(), 85 | hys.floats(min_value=0, max_value=1e9)) 86 | def test_aabb_segment_inside(a_pts, segx, segy, to_pt, fract): 87 | segx = F(segx) 88 | segy = F(segy) 89 | fract = F(fract) 90 | 91 | aabb = AABB() 92 | for pt in a_pts: 93 | aabb.include_point(pt) 94 | 95 | spt = aabb.lower + (segx, segy) * (aabb.upper - aabb.lower) 96 | 97 | endpt = spt + fract * (spt - to_pt) 98 | 99 | assert aabb.intersects_segment((spt, to_pt)) 100 | assert aabb.intersects_segment((endpt, spt)) 101 | 102 | assert aabb.intersects_segment((endpt, to_pt)) 103 | assert aabb.intersects_segment((to_pt, endpt)) 104 | 105 | 106 | @hyp.given( 107 | hys.lists(elements=points(), min_size=1, max_size=10), 108 | points(), 109 | points(), 110 | hys.sampled_from([0, 1]), 111 | hys.booleans()) 112 | def test_aabb_segment_outside(a_pts, pt1, pt2, axis, high): 113 | aabb = AABB() 114 | for pt in a_pts: 115 | aabb.include_point(pt) 116 | 117 | pt1 = [*pt1] 118 | pt2 = [*pt2] 119 | 120 | for pt in (pt1, pt2): 121 | if high: 122 | pt[axis] = max(aabb.upper[axis] + F(1, 1000000), pt[axis]) 123 | else: 124 | pt[axis] = min(aabb.lower[axis] - F(1, 1000000), pt[axis]) 125 | 126 | assert not aabb.intersects_segment((pt1, pt2)) 127 | -------------------------------------------------------------------------------- /plycutter/geometry/tests/test_benchmark.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import pytest 20 | from ..geom2d import Geom2D 21 | from ...plytypes import F 22 | 23 | 24 | def geom(n, offset=(0, 0)): 25 | pts = [] 26 | for i in range(n): 27 | pts.append((F(i) + offset[0], F(i * i) + offset[1])) 28 | return Geom2D.polygon(pts, True) 29 | 30 | 31 | @pytest.mark.parametrize('n', [12, 24, 48, 96]) 32 | @pytest.mark.benchmark() 33 | def test_intersection(n, benchmark): 34 | a = geom(n) 35 | b = geom(n, offset=(F(1, 10), F(1, 10))) 36 | benchmark(lambda: a & b) 37 | 38 | 39 | @pytest.mark.parametrize('n', [12, 24, 48, 96]) 40 | @pytest.mark.benchmark() 41 | def test_union(n, benchmark): 42 | a = geom(n) 43 | b = geom(n, offset=(F(1, 10), F(1, 10))) 44 | benchmark(lambda: a | b) 45 | 46 | 47 | @pytest.mark.parametrize('n', [12, 24, 48, 96]) 48 | @pytest.mark.benchmark() 49 | def test_dilate(n, benchmark): 50 | a = geom(n) 51 | benchmark(lambda: a.buffer(F(1, 10))) 52 | 53 | 54 | @pytest.mark.parametrize('n', [12, 24, 48, 96]) 55 | @pytest.mark.benchmark() 56 | def test_erode(n, benchmark): 57 | a = geom(n) 58 | benchmark(lambda: a.buffer(F(-1, 10))) 59 | -------------------------------------------------------------------------------- /plycutter/geometry/tests/test_geom1d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import matchpy 20 | 21 | import hypothesis as hyp 22 | import hypothesis.stateful 23 | import hypothesis.strategies as hys 24 | 25 | from ..geom1d import Geom1D 26 | 27 | from ...plytypes import Fraction 28 | 29 | # hyp.settings.register_profile('long', max_examples=2000) 30 | # hyp.settings.load_profile('long') 31 | 32 | 33 | @hys.composite 34 | def ply_fractions(draw): 35 | return Fraction(draw(hys.fractions())) 36 | 37 | 38 | @hys.composite 39 | def random_transforms(draw): 40 | c = [draw(ply_fractions()) for _ in range(2)] 41 | hyp.assume(c[0] != 0) 42 | return c 43 | 44 | 45 | def tr(transform, pt1): 46 | if transform is None: 47 | return pt1 48 | return transform[0] * pt1 + transform[1] 49 | 50 | 51 | F = Fraction 52 | 53 | 54 | def test_basics(): 55 | # inf = math.inf 56 | assert Geom1D([[0, 1], [1, 2]]).intervals == [(0, 2)] 57 | assert Geom1D([[0, 1], [2, 3]]).intervals == [(0, 1), (2, 3)] 58 | 59 | assert (Geom1D([[0, 1]]) & Geom1D( 60 | [[F(0.5), F(1.5)]])).intervals == [(F(0.5), 1)] 61 | assert (Geom1D([[0, 1]]) | Geom1D( 62 | [[F(0.5), F(1.5)]])).intervals == [(0, F(1.5))] 63 | 64 | assert (Geom1D.empty() & ~Geom1D([[-200, 0]])).is_empty() 65 | 66 | # A test version of the 2D tester... 67 | 68 | 69 | def rule(pat, call): 70 | return matchpy.ReplacementRule( 71 | matchpy.Pattern(pat), call) 72 | 73 | 74 | _a = matchpy.Wildcard.dot('a') 75 | _amount = matchpy.Wildcard.dot('amount') 76 | _b = matchpy.Wildcard.dot('b') 77 | _c = matchpy.Wildcard.dot('c') 78 | op_and = matchpy.Operation.new('op_and', matchpy.Arity.binary) 79 | op_or = matchpy.Operation.new('op_or', matchpy.Arity.binary) 80 | op_sub = matchpy.Operation.new('op_sub', matchpy.Arity.binary) 81 | op_buffer = matchpy.Operation.new('op_buffer', matchpy.Arity.binary) 82 | 83 | 84 | class SingleInterval(matchpy.Symbol): 85 | def __init__(self, vertices): 86 | super().__init__(str(vertices)) 87 | self.vertices = vertices 88 | 89 | 90 | equivalence_rules = [ 91 | rule(op_and(_a, _b), 92 | lambda a, b: op_and(b, a)), 93 | rule(op_and(_a, op_and(_b, _c)), 94 | lambda a, b, c: op_and(op_and(a, b), c)), 95 | 96 | rule(op_or(_a, _b), 97 | lambda a, b: op_or(b, a)), 98 | rule(op_or(_a, op_or(_b, _c)), 99 | lambda a, b, c: op_or(op_or(a, b), c)), 100 | 101 | rule(op_and(_a, op_or(_b, _c)), 102 | lambda a, b, c: op_or(op_and(a, b), op_and(a, c))), 103 | 104 | rule(op_sub(_a, op_or(_b, _c)), 105 | lambda a, b, c: op_and(op_sub(a, b), op_sub(a, c))), 106 | rule(op_sub(op_or(_a, _b), _c), 107 | lambda a, b, c: op_or(op_sub(a, c), op_sub(b, c))), 108 | ] 109 | 110 | 111 | def is_equivalent(g0, g0tr, g1, g1tr): 112 | pts = [] 113 | eps = Fraction(1, 1_000_000_000) 114 | for intervals in [g0.intervals, g1.intervals]: 115 | for interval in intervals: 116 | pts.append(interval[0]) 117 | pts.append(interval[0] + eps) 118 | pts.append(interval[0] - eps) 119 | pts.append(interval[1]) 120 | pts.append(interval[1] + eps) 121 | pts.append(interval[1] - eps) 122 | pts.append((interval[0] + interval[1]) / 2) 123 | for pt in pts: 124 | r0 = g0.locate(tr(g0tr, pt)) 125 | r1 = g1.locate(tr(g1tr, pt)) 126 | if r0 == r1: 127 | continue 128 | if r0 >= 0 and r1 >= 0: 129 | continue 130 | return False 131 | return True 132 | 133 | 134 | class GeomExpressionTree(hyp.stateful.RuleBasedStateMachine): 135 | """Assert invariants on generated expression trees. 136 | 137 | We generate a bunch of representations (i.e. 138 | affine transformations in which we run the same ops), 139 | and a bunch of expression trees which we evaluate 140 | in all transforms and on which we run various equivalence 141 | transforms. 142 | 143 | We can then assert that the things that should be equivalent 144 | really are 145 | """ 146 | 147 | # Names of the expressions used 148 | expr_names = hyp.stateful.Bundle('expr_names') 149 | 150 | # Indices of the representations 151 | repr_inds = hyp.stateful.Bundle('repr_inds') 152 | 153 | def evaluate(self, expr): 154 | # print('Eval', expr) 155 | cached = self.cache.get(expr, None) 156 | if cached is not None: 157 | return cached 158 | 159 | eva = self.evaluate 160 | eval_rules = [ 161 | rule(op_and(_a, _b), 162 | lambda a, b: eva(a) & eva(b)), 163 | rule(op_or(_a, _b), 164 | lambda a, b: eva(a) | eva(b)), 165 | rule(op_sub(_a, _b), 166 | lambda a, b: eva(a) - eva(b)), 167 | rule(matchpy.Wildcard.symbol('interval', SingleInterval), 168 | lambda interval: 169 | Geom1D([[interval.vertices[0], 170 | interval.vertices[1]]])), 171 | ] 172 | for eval_rule in eval_rules: 173 | matches = list(matchpy.match(expr, eval_rule.pattern)) 174 | if matches: 175 | assert len(matches) == 1 176 | result = eval_rule.replacement(**matches[0]) 177 | self.cache[expr] = result 178 | return result 179 | assert 0 == 1, expr 180 | 181 | def check(self, name): 182 | expr = self.exprs[name] 183 | # print('check expr', expr) 184 | expr = [self.evaluate(e) for e in expr] 185 | for i in range(1, len(expr)): 186 | assert is_equivalent( 187 | expr[0], self.reprs[0], 188 | expr[i], self.reprs[i]), i 189 | 190 | @hyp.stateful.initialize( 191 | target=repr_inds, 192 | transforms=hys.lists(elements=random_transforms())) 193 | def init_reprs(self, transforms): 194 | self.exprs = {} 195 | self.cache = {} 196 | self.reprs = [[1, 0]] + transforms 197 | return hyp.stateful.multiple( 198 | *list(range(len(self.reprs)))) 199 | 200 | @hyp.stateful.rule(target=expr_names, 201 | name=hys.text("abcdefghijklmnopqrstuvwxyz0123456789", 202 | ), 203 | element=hys.tuples(ply_fractions(), ply_fractions())) 204 | def add_single(self, name, element): 205 | res = [] 206 | for rep in self.reprs: 207 | res.append( 208 | SingleInterval(tuple(sorted( 209 | [tr(rep, coord) for coord in element])))) 210 | self.exprs[name] = res 211 | self.check(name) 212 | return name 213 | 214 | @hyp.stateful.rule( 215 | dst=expr_names, a=expr_names, b=expr_names, 216 | op=hys.sampled_from([ 217 | op_and, 218 | op_or, op_sub 219 | ])) 220 | def binary_op(self, dst, op, a, b): 221 | self.exprs[dst] = [op(*params) 222 | for params in zip(self.exprs[a], self.exprs[b])] 223 | self.check(dst) 224 | 225 | @hyp.stateful.rule( 226 | name=expr_names, 227 | idx=repr_inds, 228 | rules=hys.lists( 229 | elements=hys.sampled_from(equivalence_rules))) 230 | def equivalence_transform(self, name, idx, rules): 231 | expr = [e for e in self.exprs[name]] 232 | for rule in rules: 233 | expr[idx] = matchpy.replace_all(expr[idx], 234 | [rule], max_count=1) 235 | 236 | self.exprs[name] = expr 237 | self.check(name) 238 | 239 | 240 | TestGeomExpressionTree = GeomExpressionTree.TestCase 241 | -------------------------------------------------------------------------------- /plycutter/geometry/tests/test_geom2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from ..geom2d import Geom2D 20 | 21 | 22 | def test_buffer(): 23 | square = Geom2D.rectangle(0, 0, 1, 1) 24 | bsquare = square.buffer(1, resolution=16) 25 | assert bsquare.locate((0.5, 0.5)) == 1 26 | assert bsquare.locate((1.5, 0.5)) == 1 27 | assert bsquare.locate((2.5, 0.5)) == -1 28 | -------------------------------------------------------------------------------- /plycutter/geometry/types2d.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from typing import Tuple 20 | 21 | from ..plytypes import Fraction 22 | 23 | Coord = Fraction 24 | Point = Tuple[Coord, Coord] 25 | Vector = Point 26 | Segment = Tuple[Point, Point] 27 | -------------------------------------------------------------------------------- /plycutter/misc.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """Uncategorized small things 20 | """ 21 | 22 | 23 | class Autocompletable: 24 | """Fix the jupyterlab autocomplete problem for PRecords. 25 | """ 26 | 27 | def __dir__(self): 28 | return self.keys() 29 | -------------------------------------------------------------------------------- /plycutter/plytypes.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """ 20 | Provide various types used in multiple places. 21 | 22 | By editing this module, it is possible to, e.g., switch rational 23 | numbers to an alternative implementation. 24 | 25 | The reason for this class is that python very easily starts giving 26 | out floats and we want to avoid that by converting everything 27 | into fractions as soon as possible. 28 | """ 29 | 30 | import gmpy2 31 | import math 32 | import numpy as np 33 | import fractions 34 | 35 | F = Fraction = gmpy2.mpq 36 | """Constructor for rational numbers.""" 37 | 38 | FractionClass = gmpy2.mpq(1, 1).__class__ 39 | """The class that rational numbers have. 40 | 41 | Unfortunately, this is not the same as F for mpq.""" 42 | 43 | IntClass = gmpy2.mpz(1).__class__ 44 | """The class that arbitrary-precision integers have. 45 | 46 | Unfortunately, this is not the same as F for mpz.""" 47 | 48 | FractionFromFloat = gmpy2.f2q 49 | 50 | 51 | def FractionFromExact(v): 52 | """Convert an exact value into a Fraction. 53 | 54 | If the value given is not exact, throw an error. 55 | """ 56 | if v.__class__ == FractionClass: 57 | return v 58 | if v.__class__ == int or v.__class__ == fractions.Fraction or \ 59 | v.__class__ == IntClass: 60 | return Fraction(v) 61 | raise Exception('Invalid input for fraction-from-exact: %s %s' % 62 | (v, v.__class__)) 63 | 64 | 65 | def FractionFromExactOrInf(v): 66 | """Convert an exact value or an infinity into a Fraction or infinity. 67 | 68 | If the value given is not exact or infinity, throw an error. 69 | """ 70 | if abs(v) == math.inf: 71 | return v 72 | return FractionFromExact(v) 73 | 74 | 75 | def fastr(*objs): 76 | """Return a string representation where rationals are approximated. 77 | """ 78 | return fstr(*objs, approx=True) 79 | 80 | 81 | def fstr(*objs, approx=False): # noqa (recursion) 82 | """Return a string representation where rationals are shown intuitively. 83 | """ 84 | def recurse(o): 85 | if isinstance(o, np.ndarray): 86 | return recurse(o.tolist()) 87 | if type(o) is tuple: 88 | return "(" + ", ".join([recurse(m) for m in o]) + ")" 89 | if type(o) is list: 90 | return "[" + ", ".join([recurse(m) for m in o]) + "]" 91 | if type(o) is set: 92 | return "{" + ", ".join([recurse(m) for m in o]) + "}" 93 | if isinstance(o, dict): 94 | return "{" + ", ".join(f"{recurse(k)}: {recurse(v)}" 95 | for k, v in o.items()) + "}" 96 | if isinstance(o, FractionClass): 97 | if approx: 98 | f = float(o) 99 | if math.trunc(f) == f: 100 | return str(int(f)) 101 | return str(f) 102 | if o.denominator == 1: 103 | return str(o.numerator) 104 | return "%s/%s" % (o.numerator, o.denominator) 105 | if hasattr(o, '__fstr__'): 106 | return o.__fstr__(approx=approx) 107 | return str(o) 108 | return "FS/%s/" % ",".join(recurse(obj) for obj in objs) 109 | 110 | 111 | def totuple(v): 112 | """Convert a nested array / list / whatever nested tuples. 113 | 114 | Useful for creating hash keys from numpy arrays of rationals. 115 | """ 116 | v = np.asarray(v) 117 | if len(v.shape) > 0: 118 | return tuple([totuple(elem) for elem in v]) 119 | return v.tolist() 120 | -------------------------------------------------------------------------------- /plycutter/sheetbuild.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """Representation of the gradually made choices about a plycutter model. 20 | """ 21 | 22 | import pyrsistent as pyr 23 | import logging 24 | 25 | from .misc import Autocompletable 26 | from .plytypes import fastr, F, Fraction 27 | from .geometry.geom1d import Geom1D 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | # 33 | check_interval = 150 34 | check_index = 0 35 | 36 | 37 | class SheetBuild(pyr.PRecord, Autocompletable): 38 | """Data evolved by heuristics 39 | 40 | Semantics: 41 | 42 | chosen = set in stone 43 | 44 | ok = as far as we know, this could be 45 | 46 | chosen <= ok always. 47 | 48 | When chosen == ok, we are done 49 | 50 | Attributes 51 | 52 | sheet_chosen -- PMap(sheet_id, Geom2D). Sheet areas chosen 53 | to be included. 54 | sheet_ok -- PMap(sheet_id, Geom2D). Sheet areas that are 55 | not forbidden by e.g. other sheets or by not being included at all. 56 | 57 | interside_chosen -- PMap(sheet_id, Geom1D). For each interside, the regions 58 | where the interside's sheet has been chosen. 59 | 60 | interside_ok -- PMap(sheet_id, Geom1D). For each interside, the regions 61 | where it would still be ok to choose that side. 62 | 63 | There is a variety of sanity checks that can be made on a sheetbuild. 64 | The checks are relatively slow so by default they are made 65 | only infrequently (see sheetbuild.check_interval). 66 | 67 | Call sb.check(force=True) to force a check to happen. 68 | """ 69 | 70 | sheetplex = pyr.field() 71 | 72 | sheet_chosen = pyr.field() 73 | sheet_ok = pyr.field() 74 | 75 | interside_chosen = pyr.field() 76 | interside_ok = pyr.field() 77 | 78 | dbg = pyr.field() 79 | 80 | def unchoose_interside(self, interside_id, v): 81 | """State that the range v will never be chosen""" 82 | assert (self.interside_chosen[interside_id] & v).is_empty(), fastr( 83 | interside_id, v, (self.interside_chosen[interside_id] & v).area() 84 | ) 85 | interside = self.sheetplex.interside(interside_id) 86 | 87 | return SheetBuild( 88 | sheetplex=self.sheetplex, 89 | sheet_chosen=self.sheet_chosen, 90 | sheet_ok=self.sheet_ok, 91 | interside_chosen=self.interside_chosen, 92 | interside_ok=map_sub_geom(self.interside_ok, interside.id, v), 93 | ) 94 | 95 | def unchoose_sheet_area(self, sheet_id, area): 96 | assert (self.sheet_chosen[sheet_id] & area).area() < 1e-5 97 | return self.transform(["sheet_ok", sheet_id], lambda orig: orig - area) 98 | 99 | def choose_sheet_area(self, sheet_id, area): 100 | assert area.area() - (self.sheet_ok[sheet_id] & area).area() < 1e-5 101 | return self.transform( 102 | ["sheet_chosen", sheet_id], lambda orig: orig | area 103 | ) 104 | 105 | def unchoose_intersides(self, unchoices): 106 | for interside_id, v in unchoices.items(): 107 | self = self.unchoose_interside(interside_id, v) 108 | return self 109 | 110 | def choose_interside(self, interside_id, v, tentative=False): 111 | assert (~self.interside_ok[interside_id] & v).measure1d() < 1e-6, ( 112 | interside_id, 113 | self.interside_ok[interside_id], 114 | v, 115 | "INTER", 116 | (~self.interside_ok[interside_id] & v), 117 | ) 118 | interside = self.sheetplex.interside(interside_id) 119 | opposite = interside.opposite(self.sheetplex) 120 | 121 | opposite_2d = opposite.make_fingers(v) 122 | assert ( 123 | opposite_2d & self.sheet_chosen[opposite.sheet] 124 | ).area() < 1e-6, ( 125 | (opposite_2d & self.sheet_chosen[opposite.sheet]).area(), 126 | interside.sheet, 127 | opposite.sheet, 128 | ) 129 | 130 | return SheetBuild( 131 | sheetplex=self.sheetplex, 132 | sheet_chosen=self.sheet_chosen if tentative else 133 | # Can't absolutely make the choice 134 | # due to intersections 135 | map_or_geom( 136 | self.sheet_chosen, 137 | interside.sheet, 138 | interside.make_fingers(v) & self.sheet_ok[interside.sheet], 139 | ), 140 | sheet_ok=map_sub_geom( 141 | self.sheet_ok, opposite.sheet, opposite.make_fingers(v) 142 | ), 143 | interside_chosen=map_or_geom( 144 | self.interside_chosen, interside_id, v 145 | ), 146 | interside_ok=map_sub_geom(self.interside_ok, opposite.id, v), 147 | ) 148 | 149 | def choose_intersides(self, choices, tentative=False): 150 | for interside_id, v in choices.items(): 151 | self = self.choose_interside(interside_id, v, tentative=tentative) 152 | return self 153 | 154 | def check(self, force=False): 155 | """Assert that various invariants are true.""" 156 | global check_index 157 | check_index += 1 158 | if force or check_index > check_interval: 159 | check_index = 0 160 | self._check() 161 | else: 162 | logger.info("check elided") 163 | 164 | def _check(self): 165 | logger.info("check really") 166 | sp = self.sheetplex 167 | 168 | # Check that sheet_chosen <= sheet_ok 169 | for sheet in sp.sheets.values(): 170 | # The slack is allowed but should be removed now that we have 171 | # exact geometery 172 | assert ( 173 | self.sheet_chosen[sheet.id] - self.sheet_ok[sheet.id] 174 | ).area() < 1e-7, ( 175 | self.sheet_chosen[sheet.id] - self.sheet_ok[sheet.id] 176 | ).area() 177 | 178 | for interside in sp.intersides(): 179 | opposite = interside.opposite(sp) 180 | # Check that interside_chosen <= interside_ok 181 | assert ( 182 | self.interside_chosen[interside.id] 183 | - self.interside_ok[interside.id] 184 | ).measure1d() < 1e-7, ( 185 | interside.id, 186 | ( 187 | self.interside_chosen[interside.id] 188 | - self.interside_ok[interside.id] 189 | ).measure1d(), 190 | self.interside_chosen[interside.id], 191 | self.interside_ok[interside.id], 192 | ) 193 | 194 | # Check that intersection can be chosen only by one side 195 | assert ( 196 | self.interside_chosen[interside.id] 197 | & self.interside_ok[opposite.id] 198 | ).is_empty() 199 | 200 | # Check that interside_ok for an opposing side is unable 201 | # to choose parts that have been chosen in the sheet. 202 | chosen_but_other_ok = self.sheet_chosen[ 203 | interside.sheet 204 | ] & interside.make_fingers(self.interside_ok[opposite.id]) 205 | assert chosen_but_other_ok.area() < 0.001, fastr( 206 | interside.id, 207 | self.interside_ok[interside.id], 208 | self.interside_ok[opposite.id], 209 | self.interside_chosen[interside.id], 210 | self.interside_chosen[opposite.id], 211 | chosen_but_other_ok.area(), 212 | ) 213 | 214 | logger.info("check done") 215 | return self 216 | 217 | 218 | def map_or_geom(mapping, key, geom): 219 | """For a pyrsistent map, result of map[key] |= geom""" 220 | return mapping.set( 221 | key, (mapping.get(key) or geom.__class__.empty()) | geom 222 | ) 223 | 224 | 225 | def map_and_geom(mapping, key, geom): 226 | """For a pyrsistent map, result of map[key] &= geom""" 227 | return mapping.set(key, mapping.get(key) & geom) 228 | 229 | 230 | def map_sub_geom(mapping, key, geom): 231 | """For a pyrsistent map, result of map[key] -= geom""" 232 | return mapping.set(key, mapping.get(key) - geom) 233 | 234 | 235 | def map_and(mapping1, mapping2): 236 | """Return the intersection of two arbitrary maps whose values are geoms.""" 237 | return pyr.pmap( 238 | { 239 | k: v1 & mapping2[k] 240 | for k, v1 in mapping1.items() 241 | if k in mapping2 and not (v1 & mapping2[k]).is_empty() 242 | } 243 | ) 244 | 245 | 246 | def map_or(mapping1, mapping2): 247 | """Return the union of two arbitrary maps whose values are geoms.""" 248 | for k, v in mapping2.items(): 249 | mapping1 = map_or_geom(mapping1, k, v) 250 | return mapping1 251 | 252 | 253 | def create_sheetbuild(sp, params): 254 | """Create an as-free-as-possible SheetBuild for the given SheetPlex.""" 255 | max_buffer = 0 # F(1, 1000) 256 | both_faces_buffer = 1 257 | sheet_chosen = { 258 | sheet.id: (sheet.faces[0] & sheet.faces[1]).buffer(both_faces_buffer) 259 | & sheet.slices_max.buffer(max_buffer) 260 | for sheet in sp.sheets.values() 261 | } 262 | sheet_chosen_orig = {**sheet_chosen} 263 | 264 | ok_rad = params["support_radius"] 265 | sheet_ok = { 266 | sheet.id: sheet.faces[0].buffer(ok_rad) 267 | & sheet.faces[1].buffer(ok_rad) 268 | & sheet.slices_max.buffer(max_buffer) 269 | for sheet in sp.sheets.values() 270 | } 271 | 272 | interside_chosen = { 273 | interside.id: Geom1D.empty() for interside in sp.intersides() 274 | } 275 | 276 | interside_ok = { 277 | interside.id: interside.project_to_1d( 278 | sheet_ok[interside.sheet] 279 | & sp.sheets[interside.sheet].slices_max 280 | & interside.make_fingers( 281 | Geom1D([[Fraction(-1000), Fraction(1000)]]) 282 | ) 283 | ) 284 | for interside in sp.intersides() 285 | } 286 | 287 | for interside in sp.intersides(): 288 | interside_ok[interside.id] &= interside_ok[interside.opposite(sp).id] 289 | 290 | for k, v in interside_ok.items(): 291 | interside = sp.interside(k) 292 | sheet_chosen[interside.sheet] -= interside.make_fingers(v) 293 | 294 | global locs 295 | locs = locals() 296 | 297 | return SheetBuild( 298 | sheetplex=sp, 299 | sheet_chosen=pyr.pmap(sheet_chosen), 300 | sheet_ok=pyr.pmap(sheet_ok), 301 | interside_chosen=pyr.pmap(interside_chosen), 302 | interside_ok=pyr.pmap(interside_ok), 303 | ) 304 | 305 | 306 | def show_sheet(ax, sheetplex, sheetbuild, sheet_id): 307 | """Show a sheet build progress in a matplotlib axes object.""" 308 | sheetplex.sheets[sheet_id].slices_max.show2d( 309 | ax, "white", alpha=1.0, linewidth=1 310 | ) 311 | sheetbuild.sheet_ok[sheet_id].show2d(ax, "green", alpha=1.0) 312 | sheetbuild.sheet_chosen[sheet_id].show2d(ax, "cyan", linewidth=1) 313 | 314 | for interside in sheetplex.intersides(sheet_id): 315 | interside.make_fingers( 316 | sheetbuild.interside_chosen[interside.id], F(4, 10), F(6, 10) 317 | ).show2d(ax, "red", alpha=0.3) 318 | interside.make_fingers( 319 | sheetbuild.interside_ok[interside.id], F(3, 10), F(7, 10) 320 | ).show2d(ax, "blue", alpha=0.3) 321 | -------------------------------------------------------------------------------- /plycutter/sheetplex.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | """Representation for sheets extracted from a 3D model. 20 | """ 21 | 22 | import pyrsistent as pyr 23 | import numpy as np 24 | import logging 25 | 26 | from .plytypes import FractionClass, FractionFromExact 27 | from .misc import Autocompletable 28 | from .geometry.geom2d import Geom2D 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class SheetPlex(pyr.PRecord, Autocompletable): 34 | """Constant data extracted from a 3D model. 35 | 36 | Three classes represent the objects that live in a sheetplex: 37 | 38 | * `Sheet` -- a single plywood (or other material) sheet 39 | * `Intersection` -- the intersection between two sheets 40 | * `InterSide` -- one sheet's side of an Intersection 41 | 42 | The direct members are 43 | 44 | intersections -- PMap[str, Intersection] 45 | 46 | Stored in a pyrsistent.PRecord, see the methods 47 | therein for how to manipulate this object. 48 | """ 49 | 50 | sheets = pyr.field(type=pyr.PMap) 51 | """A PMap of all sheets by their id.""" 52 | 53 | intersections = pyr.field(type=pyr.PMap) 54 | """A PMap of all intersections by their id.""" 55 | 56 | def interside(self, id): 57 | """Get the interside with the given id""" 58 | inter_id, sheet_id = id 59 | sides = self.intersections[inter_id].sides 60 | if sides[0].sheet == sheet_id: 61 | return sides[0] 62 | else: 63 | assert sides[1].sheet == sheet_id 64 | return sides[1] 65 | 66 | def intersides(self, sheet_id=None): 67 | """Return an iterator for intersides of a particular sheet or all""" 68 | for intersection in self.intersections.values(): 69 | for side in intersection.sides: 70 | if sheet_id is None or side.sheet == sheet_id: 71 | yield side 72 | 73 | def limit(self, sheet_ids): 74 | """Return a sub-Sheetplex with only the given sheet_ids included""" 75 | sheet_ids = set(sheet_ids) 76 | return SheetPlex( 77 | sheets=pyr.pmap({ 78 | sheet_id: sheet for sheet_id, sheet in self.sheets.items() 79 | if sheet_id in sheet_ids 80 | }), 81 | intersections=pyr.pmap({ 82 | inter_id: inter 83 | for inter_id, inter 84 | in self.intersections.items() 85 | if inter.sides[0].sheet in sheet_ids 86 | and iter.sides[1].sheet in sheet_ids 87 | }) 88 | ) 89 | 90 | 91 | class Sheet(pyr.PRecord, Autocompletable): 92 | """A single sheet that is part of the full model. 93 | 94 | Contains various data extracted from the 3D model 95 | to help geometric reasoning about the joints. 96 | """ 97 | 98 | # Initial 99 | 100 | id = pyr.field(type=str) 101 | sides = pyr.field() 102 | slice_rotation = pyr.field() 103 | inverse_slice_rotation = pyr.field() 104 | 105 | # 2D 106 | 107 | slices = pyr.field() 108 | slices_height = pyr.field() 109 | faces = pyr.field() 110 | outside_slices = pyr.field() 111 | 112 | # Derived 113 | 114 | slices_max = pyr.field() 115 | slices_max = pyr.field() 116 | slices_min = pyr.field() 117 | both_faces = pyr.field() 118 | 119 | def normal(self, side): 120 | return self.sides[side][0:3] 121 | 122 | def offset(self, side): 123 | return self.sides[side, 3] 124 | 125 | def midplane(self): 126 | return 0.5 * np.array([1, -1]).dot(self.sides) 127 | 128 | def project_point4(self, pt): 129 | """Project a homogeneous point or line to this plane, 130 | return 2D point or direction vector for a line""" 131 | p4 = self.inverse_slice_rotation.dot(pt) 132 | p2 = p4[0:2] 133 | if p4[3] != 0: 134 | p2 /= p4[3] 135 | return p2 136 | 137 | 138 | class Intersection(pyr.PRecord, Autocompletable): 139 | """An intersection between two sheets. 140 | 141 | An intersection defines the *intersection coordinate system* 142 | which is a 1D coordinate across the intersection. 143 | 144 | The joint fingers are defined in terms of that coordinate. 145 | """ 146 | id = pyr.field(type=str) 147 | """The identifier of this intersection 148 | """ 149 | 150 | direction = pyr.field() 151 | """The 3-vector direction of the intersection in 3-space. 152 | """ 153 | 154 | origins = pyr.field() 155 | """Origins of all intersection lines between the two intersecting sheets. 156 | """ 157 | 158 | sides = pyr.field(type=tuple) 159 | """The ``InterSide`` objects of this intersection. 160 | 161 | The ``InterSide`` objects store information about the location 162 | of the intersection within the sheets themselves. 163 | """ 164 | 165 | def sheets(self): 166 | """Return a tuple of the particpating sheet ids """ 167 | return [interside.sheet for interside in self.sides] 168 | 169 | def side_by_sheet(self, sheet_id): 170 | """Get the ``InterSide`` for the given sheet_id. 171 | 172 | Returns: 173 | None if the given ``sheet_id`` is not in this intersection. 174 | """ 175 | for side in self.sides: 176 | if side.sheet == sheet_id: 177 | return side 178 | return None 179 | 180 | 181 | def robust_isfinite(v): 182 | """A version of isfinite that also works on huge rationals""" 183 | return v + 1 > v 184 | 185 | 186 | class InterSide(pyr.PRecord, Autocompletable): 187 | """A particular sheet's view of an intersection. 188 | """ 189 | id = pyr.field(type=tuple) 190 | """The identifier of the InterSide. 191 | """ 192 | 193 | intersection = pyr.field(type=str) 194 | """The id of the ``Intersection`` this ``InterSide`` belongs to 195 | """ 196 | 197 | idx = pyr.field(type=int) 198 | """The index of this ``InterSide`` within the intersection. 199 | """ 200 | 201 | sheet = pyr.field(type=str) 202 | """The id of the `Sheet` on which this ``InterSide`` lies. 203 | """ 204 | 205 | direction = pyr.field() 206 | """The Fraction 2-vector direction on the 2D sheet. 207 | """ 208 | 209 | origin_offset = pyr.field(type=FractionClass) 210 | """The offset along ``direction`` to the intersections coordinate's origin 211 | """ 212 | 213 | normal = pyr.field() 214 | """The normal vector to the intersection in the 2D plane. 215 | """ 216 | 217 | min_normal_offset = pyr.field(type=FractionClass) 218 | """The minimum value of normal dot pt that belongs to the intersection. 219 | """ 220 | 221 | max_normal_offset = pyr.field(type=FractionClass) 222 | """The maximum value of normal dot pt that belongs to the intersection. 223 | """ 224 | 225 | def opposite(self, sheetplex): 226 | """Get the other InterSide in the Intersection we are part of""" 227 | return sheetplex.intersections[self.intersection].sides[1 - self.idx] 228 | 229 | def project_to_1d(self, geom2d): 230 | """Project a Geom2D in Sheet coords to a Geom1D in Intersection coords. 231 | """ 232 | return geom2d.project_to_1d(self.direction, -self.origin_offset) 233 | 234 | def make_fingers(self, where, fract0=0, fract1=1): 235 | """ 236 | Create a geom for fingers on the sheet on this InterSide. 237 | 238 | The parameters fract0 and fract1 are 239 | useful mostly for visualizations of different rules affecting 240 | the fingers. 241 | 242 | Parameters: 243 | where: Geom1D of sections along joint 244 | fract0: From this fraction coordinate across joint 245 | fract1: To this fraction coordinate across joint 246 | Returns: 247 | 2D geometry of fingers. 248 | """ 249 | res = [] 250 | fract0 = FractionFromExact(fract0) 251 | fract1 = FractionFromExact(fract1) 252 | for interval in where.get_intervals(): 253 | assert isinstance(interval[0], FractionClass) 254 | assert isinstance(interval[1], FractionClass) 255 | dire = self.direction 256 | norm = self.normal 257 | offs = self.origin_offset 258 | 259 | def coords(along, across): 260 | assert robust_isfinite(along), along 261 | al = (along + offs) 262 | 263 | fract = fract0 + (fract1 - fract0) * across 264 | nc = self.min_normal_offset + \ 265 | (self.max_normal_offset - self.min_normal_offset) * fract 266 | result = al * dire + nc * norm 267 | assert np.all([isinstance(r, FractionClass) 268 | for r in result]), result 269 | return result 270 | 271 | # logger.debug('mkfingers %s', 272 | # (self.id, fstr(interval, dire, norm, offs, fract0, fract1))) 273 | 274 | res.append(Geom2D.polygon([ 275 | coords(interval[0], 0), 276 | coords(interval[0], 1), 277 | coords(interval[1], 1), 278 | coords(interval[1], 0), 279 | ], reorient=True)) 280 | return Geom2D.union(res) 281 | -------------------------------------------------------------------------------- /plycutter/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | -------------------------------------------------------------------------------- /plycutter/tests/test_command_line.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | from .. import command_line 20 | 21 | 22 | def test_smoke(): 23 | command_line.main(['--thickness', '6', './tests/data/PlyTest0.1.stl']) 24 | -------------------------------------------------------------------------------- /plycutter/writer.py: -------------------------------------------------------------------------------- 1 | # 2 | # plycutter - generate finger-jointed laser cutter templates from 3D objects 3 | # Copyright (C) 2020 Tuomas J. Lukka 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | # 18 | 19 | import ezdxf 20 | import numpy as np 21 | import logging 22 | 23 | # XXX 24 | from .geometry.aabb import AABB 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def write_svg(filename, geom2ds): 30 | file = open(filename, "w") 31 | file.write('\n') 32 | file.write('\n') 33 | 34 | # Trivial nesting horizontally 35 | x = 0 36 | for name, geom in geom2ds.items(): 37 | print(name) 38 | if geom.is_empty(): 39 | logger.warn(f"Empty sheet {name}") 40 | continue 41 | 42 | aabb = AABB() 43 | 44 | for polygon in geom.polygons(): 45 | coords = np.array(polygon.spwhs[0].outer) 46 | for pt in coords: 47 | aabb.include_point(pt) 48 | 49 | margin = 3 50 | x_offset = x - aabb.lower[0] + margin 51 | y_offset = 0 - aabb.lower[1] 52 | x += aabb.upper[0] - aabb.lower[0] + margin 53 | 54 | def draw_coords(coords): 55 | coords = list(coords) 56 | coords = np.array(coords + coords[0:1]) # Loop 57 | coords = coords + [x_offset, y_offset] 58 | coords = coords.astype(np.float64) 59 | assert np.all(np.isfinite(coords)) 60 | path = "M " 61 | for coord in coords: 62 | path += "{:0.6f},{:0.6f} ".format(coord[0], coord[1]) 63 | style = ( 64 | "fill:none;stroke:#000000;" 65 | "stroke-width:1px;stroke-opacity:1.0" 66 | ) 67 | file.write(f"""\n""") 68 | 69 | for polygon in geom.polygons(): 70 | draw_coords(polygon.spwhs[0].outer) 71 | for hole in polygon.spwhs[0].holes: 72 | draw_coords(hole) 73 | file.write("") 74 | file.close() 75 | 76 | 77 | def write_dxf(filename, geom2ds): 78 | dwg = ezdxf.new("AC1015") 79 | modelspace = dwg.modelspace() 80 | 81 | # Trivial nesting horizontally 82 | x = 0 83 | for name, geom in geom2ds.items(): 84 | print(name) 85 | if geom.is_empty(): 86 | logger.warn(f"Empty sheet {name}") 87 | continue 88 | 89 | aabb = AABB() 90 | 91 | for polygon in geom.polygons(): 92 | coords = np.array(polygon.spwhs[0].outer) 93 | for pt in coords: 94 | aabb.include_point(pt) 95 | 96 | margin = 3 97 | x_offset = x - aabb.lower[0] + margin 98 | y_offset = 0 - aabb.lower[1] 99 | x += aabb.upper[0] - aabb.lower[0] + margin 100 | 101 | def draw_coords(coords): 102 | coords = list(coords) 103 | coords = np.array(coords + coords[0:1]) # Loop 104 | coords = coords + [x_offset, y_offset] 105 | coords = coords.astype(np.float64) 106 | assert np.all(np.isfinite(coords)) 107 | modelspace.add_lwpolyline(coords.tolist()) 108 | 109 | for polygon in geom.polygons(): 110 | draw_coords(polygon.spwhs[0].outer) 111 | for hole in polygon.spwhs[0].holes: 112 | draw_coords(hole) 113 | 114 | dwg.saveas(filename) 115 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | pyrsistent 3 | ezdxf 4 | gmpy2 5 | trimesh 6 | shapely 7 | hypothesis 8 | matchpy 9 | pytest-benchmark 10 | sortedcontainers 11 | matplotlib 12 | scipy 13 | networkx 14 | rtree 15 | mkdocs 16 | mkdocs-material 17 | mkdocstrings 18 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = plycutter 3 | version = 0.0 4 | description = Slice 3D shell models into finger-jointed laser-cut parts 5 | url = http://github.com/tjltjl/plycutter 6 | keywords = laser cutting, CAM 7 | author = Tuomas Lukka 8 | author_email = tuomas@hipcode.fi 9 | license = AGPL-3.0-or-later 10 | 11 | [options] 12 | python_requires = >=3.7.0 13 | packages = find: 14 | zip_safe= True 15 | tests_require = 16 | hypothesis 17 | pytest-benchmark 18 | matchpy 19 | install_requires = 20 | numpy 21 | pyrsistent 22 | ezdxf 23 | gmpy2 24 | trimesh 25 | shapely 26 | sortedcontainers 27 | matplotlib 28 | scipy # Soft dep of part of trimesh we need 29 | networkx # Soft dep of part of trimesh we need 30 | rtree # Soft dep of part of trimesh we need 31 | 32 | [options.extras_require] 33 | docs = 34 | mkdocs 35 | mkdocs-material 36 | mkdocstrings 37 | 38 | [options.entry_points] 39 | console_scripts = 40 | plycutter = plycutter.command_line:main 41 | 42 | [flake8] 43 | # E203 Whitespace before : that black produces 44 | # W503 Linebreak after binary op 45 | # E231 Missing whitespace after , (black: [a, b,]) 46 | # C901 Too complex 47 | ignore=E203, W503, E231, C901 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # from KOLANICH/python_project_boilerplate.py 4 | 5 | from setuptools import setup 6 | 7 | if __name__ == "__main__": 8 | setup() 9 | -------------------------------------------------------------------------------- /tests/data/PlyTest0.1.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tjltjl/plycutter/fb62f664772541003a5c8880be9378caba204603/tests/data/PlyTest0.1.stl --------------------------------------------------------------------------------