├── .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 |
6 |
7 |
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 |
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 |
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 |
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 |
34 |
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 |
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 |
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('")
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
--------------------------------------------------------------------------------