├── .coveragerc
├── .gitignore
├── .travis.yml
├── AUTHORS.rst
├── LICENSE.rst
├── MANIFEST.in
├── README.rst
├── docs
├── Makefile
├── _static
│ └── img
│ │ ├── examples
│ │ ├── angle_pie.png
│ │ ├── bars_nd.png
│ │ ├── cogwheel.png
│ │ ├── csv.png
│ │ └── tower.png
│ │ ├── shapes
│ │ ├── angle_height_pie_2d.png
│ │ ├── angle_pie_1d.png
│ │ ├── angle_radius_height_pie_3d.png
│ │ ├── angle_radius_pie_2d.png
│ │ ├── bars_1d.png
│ │ ├── bars_2d.png
│ │ ├── bars_3d.png
│ │ ├── bars_grouped_1d.png
│ │ ├── barsnd.png
│ │ ├── circle_tower_1d.png
│ │ ├── height_pie_1d.png
│ │ ├── quadrilateral_tower_4d.png
│ │ ├── radius_height_pie_2d.png
│ │ ├── radius_pie_1d.png
│ │ ├── rectangle_tower_2d.png
│ │ ├── rhombus_tower_2d.png
│ │ └── square_tower_1d.png
│ │ └── usage_tower.png
├── ast.rst
├── backends.rst
├── conf.py
├── examples.rst
├── generate_pictures.py
├── index.rst
├── installing.rst
├── make.bat
├── scales.rst
├── shapes.rst
├── shapes
│ ├── bars.rst
│ ├── pie.rst
│ └── vertical.rst
├── usage.rst
└── utils.rst
├── example1.jpg
├── examples
├── analytics-full-13.csv
├── analytics-sep-13.csv
├── angle_height_pie_2d.py
├── angle_pie_1d.py
├── angle_radius_height_pie_3d.py
├── angle_radius_pie_2d.py
├── bars_1d.py
├── barsnd.py
├── circle_tower_1d.py
├── height_pie_1d.py
├── quadrilateral_tower_4d.py
├── radius_height_pie_2d.py
├── radius_pie_1d.py
├── rectangle_tower_2d.py
├── rhombus_tower_2d.py
├── square_tower_1d.py
├── test_preamble.py
└── test_quads.py
├── pytest.ini
├── requirements-dev.txt
├── setup.py
├── tangible
├── __init__.py
├── ast.py
├── backends
│ ├── __init__.py
│ └── openscad.py
├── scales.py
├── shapes
│ ├── __init__.py
│ ├── bars.py
│ ├── base.py
│ ├── mixins.py
│ ├── pie.py
│ └── vertical.py
└── utils.py
└── tests
├── test_ast.py
├── test_backend_openscad.py
├── test_examples.py
├── test_mixins.py
├── test_scales.py
├── test_shapes.py
└── test_utils.py
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | source =
3 | tangible
4 | omit =
5 | tests/*
6 |
7 | [report]
8 | # Regexes for lines to exclude from consideration
9 | exclude_lines =
10 | # Don't complain about missing debug-only code:
11 | def __repr__
12 | if self\.debug
13 |
14 | # Don't complain if tests don't hit defensive assertion code:
15 | raise AssertionError
16 | raise NotImplementedError
17 |
18 | # Don't complain if non-runnable code isn't run:
19 | if 0:
20 | if __name__ == .__main__.:
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 |
3 | # C extensions
4 | *.so
5 |
6 | # Packages
7 | *.egg
8 | *.egg-info
9 | dist
10 | build
11 | eggs
12 | parts
13 | bin
14 | var
15 | sdist
16 | develop-eggs
17 | .installed.cfg
18 | lib
19 | lib64
20 |
21 | # Installer logs
22 | pip-log.txt
23 |
24 | # Unit test / coverage reports
25 | .coverage
26 | .coverage.*
27 | .tox
28 | nosetests.xml
29 | .cache/
30 |
31 | # Translations
32 | *.mo
33 |
34 | # Developer Tools
35 | .mr.developer.cfg
36 | .project
37 | .pydevproject
38 | .idea
39 | *.sw[op]
40 |
41 | # Sphinx
42 | docs/_build
43 |
44 | # Examples
45 | examples/*.scad
46 | examples/*.png
47 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: trusty
2 | sudo: false
3 | language: python
4 | python:
5 | - 2.7
6 | - 3.4
7 | - 3.5
8 | - 3.6
9 | matrix:
10 | fast_finish: true
11 | install:
12 | - pip install -r requirements-dev.txt
13 | - pip install -e .
14 | script:
15 | - pytest
16 | after_script:
17 | - pip install --quiet coveralls
18 | - coveralls
19 |
--------------------------------------------------------------------------------
/AUTHORS.rst:
--------------------------------------------------------------------------------
1 | Main Authors
2 | ------------
3 |
4 | - Danilo Bargen (@dbrgn)
5 |
6 | Contributors
7 | ------------
8 |
9 | ...
10 |
--------------------------------------------------------------------------------
/LICENSE.rst:
--------------------------------------------------------------------------------
1 | LGPLv3 or later. See https://www.gnu.org/licenses/lgpl.html.
2 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst LICENSE.rst AUTHORS.rst
2 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Tangible
2 | ========
3 |
4 | .. image:: https://img.shields.io/travis/dbrgn/tangible/master.svg
5 | :alt: Travis-CI build status
6 | :target: http://travis-ci.org/dbrgn/tangible
7 |
8 | .. image:: https://img.shields.io/coveralls/dbrgn/tangible/master.svg
9 | :target: https://coveralls.io/r/dbrgn/tangible
10 | :alt: Coverage Status
11 |
12 | .. image:: https://img.shields.io/pypi/v/tangible.svg
13 | :target: https://pypi.python.org/pypi/tangible/
14 | :alt: Latest Version
15 |
16 | .. image:: https://img.shields.io/pypi/wheel/tangible.svg
17 | :target: https://pypi.python.org/pypi/tangible/
18 | :alt: Wheel Availability
19 |
20 | *Tangible* is a Python library to convert data into tangible 3D models. It
21 | generates code for different backends like *OpenSCAD* or *ImplicitCAD*. It is
22 | inspired by projects like *OpenSCAD* and *d3.js*.
23 |
24 | .. image:: https://raw.github.com/dbrgn/tangible/master/example1.jpg
25 | :alt: Example 1
26 |
27 | Implementation
28 | --------------
29 |
30 | The difference from Projects like *SolidPython* is that *Tangible* is a modular
31 | system with an intermediate representation of objects that is capable of
32 | generating code for different backends, not just *OpenSCAD*. Additionally, its
33 | main focus is not general CAD, but printable 3D visualization of data.
34 |
35 | The workflow to get a real object from data is as follows::
36 |
37 | Python code => Intermediate representation (AST) => Programmatic CAD code
38 | => STL file => Slicer => G code => 3D printer => Tangible object
39 |
40 | Of these, *Tangible* does the first three steps. The fourth step is handled by
41 | a programmatic CAD tool like *OpenSCAD* or *ImplicitCAD* and the last four
42 | steps are handled by the specific 3D printer software.
43 |
44 | Currently supported Python version is 2.7 and 3.4+.
45 |
46 | This library was my student research project thesis at `HSR `_.
47 | You can find the thesis paper here: https://files.dbrgn.ch/sa-thesis.pdf
48 |
49 | Contributions are very welcome! Please open an issue or a pull request.
50 |
51 |
52 | Installation
53 | ------------
54 |
55 | You can install Tangible directly via PyPI::
56 |
57 | pip install tangible
58 |
59 | If you want the current development version::
60 |
61 | pip install -e git+https://github.com/dbrgn/tangible#egg=tangible-dev
62 |
63 |
64 | Documentation
65 | -------------
66 |
67 | Documentation can be found on ReadTheDocs: `http://tangible.readthedocs.org/
68 | `_
69 |
70 | If you want to know more about the architecture of the library, please refer to
71 | my `thesis PDF `_.
72 |
73 |
74 | Coding Guidelines
75 | -----------------
76 |
77 | `PEP8 `__ via `flake8
78 | `_ with max-line-width set to 99 and
79 | E126-E128,E266,E731 ignored.
80 |
81 | All Python files must start with an UTF8 encoding declaration and some
82 | `future-imports `_:
83 |
84 | .. sourcecode:: python
85 |
86 | # -*- coding: utf-8 -*-
87 | from __future__ import print_function, division, absolute_import, unicode_literals
88 |
89 | Docstrings convention: `Sphinx style `__.
90 |
91 |
92 | Testing
93 | -------
94 |
95 | Prepare::
96 |
97 | pip install -r requirements-dev.txt --use-mirrors
98 | pip install -e .
99 |
100 | Run tests::
101 |
102 | pytest
103 |
104 | Violations of the PEP8 coding guidelines above will be counted as test fails.
105 |
106 |
107 | Versioning
108 | ----------
109 |
110 | Tangible implements `Semantic Versioning 2.0
111 | `_.
112 |
113 |
114 | License
115 | -------
116 |
117 | LGPLv3 or later `http://www.gnu.org/licenses/lgpl.html
118 | `_
119 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | PAPER =
8 | BUILDDIR = _build
9 |
10 | # Internal variables.
11 | PAPEROPT_a4 = -D latex_paper_size=a4
12 | PAPEROPT_letter = -D latex_paper_size=letter
13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
14 | # the i18n builder cannot share the environment and doctrees with the others
15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
16 |
17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
18 |
19 | help:
20 | @echo "Please use \`make ' where is one of"
21 | @echo " html to make standalone HTML files"
22 | @echo " dirhtml to make HTML files named index.html in directories"
23 | @echo " singlehtml to make a single large HTML file"
24 | @echo " pickle to make pickle files"
25 | @echo " json to make JSON files"
26 | @echo " htmlhelp to make HTML files and a HTML help project"
27 | @echo " qthelp to make HTML files and a qthelp project"
28 | @echo " devhelp to make HTML files and a Devhelp project"
29 | @echo " epub to make an epub"
30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
31 | @echo " latexpdf to make LaTeX files and run them through pdflatex"
32 | @echo " text to make text files"
33 | @echo " man to make manual pages"
34 | @echo " texinfo to make Texinfo files"
35 | @echo " info to make Texinfo files and run them through makeinfo"
36 | @echo " gettext to make PO message catalogs"
37 | @echo " changes to make an overview of all changed/added/deprecated items"
38 | @echo " linkcheck to check all external links for integrity"
39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)"
40 |
41 | clean:
42 | -rm -rf $(BUILDDIR)/*
43 |
44 | html:
45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
46 | @echo
47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
48 |
49 | dirhtml:
50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
51 | @echo
52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
53 |
54 | singlehtml:
55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
56 | @echo
57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
58 |
59 | pickle:
60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
61 | @echo
62 | @echo "Build finished; now you can process the pickle files."
63 |
64 | json:
65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
66 | @echo
67 | @echo "Build finished; now you can process the JSON files."
68 |
69 | htmlhelp:
70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
71 | @echo
72 | @echo "Build finished; now you can run HTML Help Workshop with the" \
73 | ".hhp project file in $(BUILDDIR)/htmlhelp."
74 |
75 | qthelp:
76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
77 | @echo
78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \
79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Tangible.qhcp"
81 | @echo "To view the help file:"
82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Tangible.qhc"
83 |
84 | devhelp:
85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
86 | @echo
87 | @echo "Build finished."
88 | @echo "To view the help file:"
89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Tangible"
90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Tangible"
91 | @echo "# devhelp"
92 |
93 | epub:
94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
95 | @echo
96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
97 |
98 | latex:
99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
100 | @echo
101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \
103 | "(use \`make latexpdf' here to do that automatically)."
104 |
105 | latexpdf:
106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
107 | @echo "Running LaTeX files through pdflatex..."
108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf
109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
110 |
111 | text:
112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
113 | @echo
114 | @echo "Build finished. The text files are in $(BUILDDIR)/text."
115 |
116 | man:
117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
118 | @echo
119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
120 |
121 | texinfo:
122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
123 | @echo
124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
125 | @echo "Run \`make' in that directory to run these through makeinfo" \
126 | "(use \`make info' here to do that automatically)."
127 |
128 | info:
129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
130 | @echo "Running Texinfo files through makeinfo..."
131 | make -C $(BUILDDIR)/texinfo info
132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
133 |
134 | gettext:
135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
136 | @echo
137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
138 |
139 | changes:
140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
141 | @echo
142 | @echo "The overview file is in $(BUILDDIR)/changes."
143 |
144 | linkcheck:
145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
146 | @echo
147 | @echo "Link check complete; look for any errors in the above output " \
148 | "or in $(BUILDDIR)/linkcheck/output.txt."
149 |
150 | doctest:
151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
152 | @echo "Testing of doctests in the sources finished, look at the " \
153 | "results in $(BUILDDIR)/doctest/output.txt."
154 |
--------------------------------------------------------------------------------
/docs/_static/img/examples/angle_pie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/examples/angle_pie.png
--------------------------------------------------------------------------------
/docs/_static/img/examples/bars_nd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/examples/bars_nd.png
--------------------------------------------------------------------------------
/docs/_static/img/examples/cogwheel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/examples/cogwheel.png
--------------------------------------------------------------------------------
/docs/_static/img/examples/csv.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/examples/csv.png
--------------------------------------------------------------------------------
/docs/_static/img/examples/tower.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/examples/tower.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/angle_height_pie_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/angle_height_pie_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/angle_pie_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/angle_pie_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/angle_radius_height_pie_3d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/angle_radius_height_pie_3d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/angle_radius_pie_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/angle_radius_pie_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/bars_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/bars_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/bars_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/bars_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/bars_3d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/bars_3d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/bars_grouped_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/bars_grouped_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/barsnd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/barsnd.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/circle_tower_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/circle_tower_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/height_pie_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/height_pie_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/quadrilateral_tower_4d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/quadrilateral_tower_4d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/radius_height_pie_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/radius_height_pie_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/radius_pie_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/radius_pie_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/rectangle_tower_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/rectangle_tower_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/rhombus_tower_2d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/rhombus_tower_2d.png
--------------------------------------------------------------------------------
/docs/_static/img/shapes/square_tower_1d.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/shapes/square_tower_1d.png
--------------------------------------------------------------------------------
/docs/_static/img/usage_tower.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/docs/_static/img/usage_tower.png
--------------------------------------------------------------------------------
/docs/ast.rst:
--------------------------------------------------------------------------------
1 | .. _ast:
2 |
3 | AST
4 | ===
5 |
6 | The abstract syntax tree provides different shape types and transformations that
7 | can be nested. They're largely inspired by `OpenSCAD
8 | `_.
9 |
10 |
11 | 2D Shapes
12 | ---------
13 |
14 | .. autoclass:: tangible.ast.Circle
15 | :members:
16 |
17 | .. autoclass:: tangible.ast.CircleSector
18 | :members:
19 |
20 | .. autoclass:: tangible.ast.Rectangle
21 | :members:
22 |
23 | .. autoclass:: tangible.ast.Polygon
24 | :members:
25 |
26 |
27 | 3D Shapes
28 | ---------
29 |
30 | .. autoclass:: tangible.ast.Cube
31 | :members:
32 |
33 | .. autoclass:: tangible.ast.Sphere
34 | :members:
35 |
36 | .. autoclass:: tangible.ast.Cylinder
37 | :members:
38 |
39 | .. autoclass:: tangible.ast.Polyhedron
40 | :members:
41 |
42 |
43 | Transformations
44 | ---------------
45 |
46 | .. autoclass:: tangible.ast.Translate
47 | :members:
48 |
49 | .. autoclass:: tangible.ast.Rotate
50 | :members:
51 |
52 | .. autoclass:: tangible.ast.Scale
53 | :members:
54 |
55 | .. autoclass:: tangible.ast.Mirror
56 | :members:
57 |
58 |
59 | Boolean operations
60 | ------------------
61 |
62 | .. autoclass:: tangible.ast.Union
63 | :members:
64 |
65 | .. autoclass:: tangible.ast.Difference
66 | :members:
67 |
68 | .. autoclass:: tangible.ast.Intersection
69 | :members:
70 |
71 |
72 | Extrusions
73 | ----------
74 |
75 | .. autoclass:: tangible.ast.LinearExtrusion
76 | :members:
77 |
78 | .. autoclass:: tangible.ast.RotateExtrusion
79 | :members:
80 |
--------------------------------------------------------------------------------
/docs/backends.rst:
--------------------------------------------------------------------------------
1 | .. _backends:
2 |
3 | Backends
4 | ========
5 |
6 | .. _backends_openscad:
7 |
8 | OpenSCAD Backend
9 | ----------------
10 |
11 | This backend allows generating of OpenSCAD code. To convert the code into an
12 | STL file, you can either copy-and-paste the code into the GUI tool, or you can
13 | render it directly using the ``openscad`` command line tool:
14 |
15 | .. sourcecode:: bash
16 |
17 | python exampleModel.py > exampleModel.scad
18 | openscad -o exampleModel.stl --render exampleModel.scad
19 |
20 | .. autoclass:: tangible.backends.openscad.OpenScadBackend
21 | :members:
22 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Tangible documentation build configuration file, created by
4 | # sphinx-quickstart on Wed Oct 16 17:16:59 2013.
5 | #
6 | # This file is execfile()d with the current directory set to its containing dir.
7 | #
8 | # Note that not all possible configuration values are present in this
9 | # autogenerated file.
10 | #
11 | # All configuration values have a default; values that are commented out
12 | # serve to show the default.
13 |
14 | import sys, os
15 |
16 | # If extensions (or modules to document with autodoc) are in another directory,
17 | # add these directories to sys.path here. If the directory is relative to the
18 | # documentation root, use os.path.abspath to make it absolute, like shown here.
19 | sys.path.insert(0, os.path.abspath('..'))
20 |
21 | # -- General configuration -----------------------------------------------------
22 |
23 | # If your documentation needs a minimal Sphinx version, state it here.
24 | #needs_sphinx = '1.0'
25 |
26 | # Add any Sphinx extension module names here, as strings. They can be extensions
27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx',
29 | 'sphinx.ext.todo', 'sphinx.ext.pngmath', 'sphinx.ext.viewcode',
30 | 'sphinx.ext.inheritance_diagram']
31 |
32 | # Add any paths that contain templates here, relative to this directory.
33 | templates_path = ['_templates']
34 |
35 | # The suffix of source filenames.
36 | source_suffix = '.rst'
37 |
38 | # The encoding of source files.
39 | #source_encoding = 'utf-8-sig'
40 |
41 | # The master toctree document.
42 | master_doc = 'index'
43 |
44 | # General information about the project.
45 | project = u'Tangible'
46 | copyright = u'2013, Danilo Bargen'
47 |
48 | # The version info for the project you're documenting, acts as replacement for
49 | # |version| and |release|, also used in various other places throughout the
50 | # built documents.
51 | #
52 | # The short X.Y version.
53 | version = '0.2'
54 | # The full version, including alpha/beta/rc tags.
55 | release = '0.2.2'
56 |
57 | # The language for content autogenerated by Sphinx. Refer to documentation
58 | # for a list of supported languages.
59 | #language = None
60 |
61 | # There are two options for replacing |today|: either, you set today to some
62 | # non-false value, then it is used:
63 | #today = ''
64 | # Else, today_fmt is used as the format for a strftime call.
65 | #today_fmt = '%B %d, %Y'
66 |
67 | # List of patterns, relative to source directory, that match files and
68 | # directories to ignore when looking for source files.
69 | exclude_patterns = ['_build']
70 |
71 | # The reST default role (used for this markup: `text`) to use for all documents.
72 | #default_role = None
73 |
74 | # If true, '()' will be appended to :func: etc. cross-reference text.
75 | #add_function_parentheses = True
76 |
77 | # If true, the current module name will be prepended to all description
78 | # unit titles (such as .. function::).
79 | #add_module_names = True
80 |
81 | # If true, sectionauthor and moduleauthor directives will be shown in the
82 | # output. They are ignored by default.
83 | #show_authors = False
84 |
85 | # The name of the Pygments (syntax highlighting) style to use.
86 | pygments_style = 'sphinx'
87 |
88 | # A list of ignored prefixes for module index sorting.
89 | #modindex_common_prefix = []
90 |
91 | # Show __init__ methods
92 | autoclass_content = 'both'
93 |
94 |
95 | # -- Options for HTML output ---------------------------------------------------
96 |
97 | # The theme to use for HTML and HTML Help pages. See the documentation for
98 | # a list of builtin themes.
99 | html_theme = 'default'
100 |
101 | # Theme options are theme-specific and customize the look and feel of a theme
102 | # further. For a list of options available for each theme, see the
103 | # documentation.
104 | #html_theme_options = {}
105 |
106 | # Add any paths that contain custom themes here, relative to this directory.
107 | #html_theme_path = []
108 |
109 | # The name for this set of Sphinx documents. If None, it defaults to
110 | # " v documentation".
111 | #html_title = None
112 |
113 | # A shorter title for the navigation bar. Default is the same as html_title.
114 | #html_short_title = None
115 |
116 | # The name of an image file (relative to this directory) to place at the top
117 | # of the sidebar.
118 | #html_logo = None
119 |
120 | # The name of an image file (within the static path) to use as favicon of the
121 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
122 | # pixels large.
123 | #html_favicon = None
124 |
125 | # Add any paths that contain custom static files (such as style sheets) here,
126 | # relative to this directory. They are copied after the builtin static files,
127 | # so a file named "default.css" will overwrite the builtin "default.css".
128 | html_static_path = ['_static']
129 |
130 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
131 | # using the given strftime format.
132 | #html_last_updated_fmt = '%b %d, %Y'
133 |
134 | # If true, SmartyPants will be used to convert quotes and dashes to
135 | # typographically correct entities.
136 | #html_use_smartypants = True
137 |
138 | # Custom sidebar templates, maps document names to template names.
139 | #html_sidebars = {}
140 |
141 | # Additional templates that should be rendered to pages, maps page names to
142 | # template names.
143 | #html_additional_pages = {}
144 |
145 | # If false, no module index is generated.
146 | #html_domain_indices = True
147 |
148 | # If false, no index is generated.
149 | #html_use_index = True
150 |
151 | # If true, the index is split into individual pages for each letter.
152 | #html_split_index = False
153 |
154 | # If true, links to the reST sources are added to the pages.
155 | #html_show_sourcelink = True
156 |
157 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
158 | #html_show_sphinx = True
159 |
160 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
161 | #html_show_copyright = True
162 |
163 | # If true, an OpenSearch description file will be output, and all pages will
164 | # contain a tag referring to it. The value of this option must be the
165 | # base URL from which the finished HTML is served.
166 | #html_use_opensearch = ''
167 |
168 | # This is the file name suffix for HTML files (e.g. ".xhtml").
169 | #html_file_suffix = None
170 |
171 | # Output file base name for HTML help builder.
172 | htmlhelp_basename = 'Tangibledoc'
173 |
174 |
175 | # -- Options for LaTeX output --------------------------------------------------
176 |
177 | latex_elements = {
178 | # The paper size ('letterpaper' or 'a4paper').
179 | #'papersize': 'letterpaper',
180 |
181 | # The font size ('10pt', '11pt' or '12pt').
182 | #'pointsize': '10pt',
183 |
184 | # Additional stuff for the LaTeX preamble.
185 | #'preamble': '',
186 | }
187 |
188 | # Grouping the document tree into LaTeX files. List of tuples
189 | # (source start file, target name, title, author, documentclass [howto/manual]).
190 | latex_documents = [
191 | ('index', 'Tangible.tex', u'Tangible Documentation',
192 | u'Danilo Bargen', 'manual'),
193 | ]
194 |
195 | # The name of an image file (relative to this directory) to place at the top of
196 | # the title page.
197 | #latex_logo = None
198 |
199 | # For "manual" documents, if this is true, then toplevel headings are parts,
200 | # not chapters.
201 | #latex_use_parts = False
202 |
203 | # If true, show page references after internal links.
204 | #latex_show_pagerefs = False
205 |
206 | # If true, show URL addresses after external links.
207 | #latex_show_urls = False
208 |
209 | # Documents to append as an appendix to all manuals.
210 | #latex_appendices = []
211 |
212 | # If false, no module index is generated.
213 | #latex_domain_indices = True
214 |
215 |
216 | # -- Options for manual page output --------------------------------------------
217 |
218 | # One entry per manual page. List of tuples
219 | # (source start file, name, description, authors, manual section).
220 | man_pages = [
221 | ('index', 'tangible', u'Tangible Documentation',
222 | [u'Danilo Bargen'], 1)
223 | ]
224 |
225 | # If true, show URL addresses after external links.
226 | #man_show_urls = False
227 |
228 |
229 | # -- Options for Texinfo output ------------------------------------------------
230 |
231 | # Grouping the document tree into Texinfo files. List of tuples
232 | # (source start file, target name, title, author,
233 | # dir menu entry, description, category)
234 | texinfo_documents = [
235 | ('index', 'Tangible', u'Tangible Documentation',
236 | u'Danilo Bargen', 'Tangible', 'One line description of project.',
237 | 'Miscellaneous'),
238 | ]
239 |
240 | # Documents to append as an appendix to all manuals.
241 | #texinfo_appendices = []
242 |
243 | # If false, no module index is generated.
244 | #texinfo_domain_indices = True
245 |
246 | # How to display URL addresses: 'footnote', 'no', or 'inline'.
247 | #texinfo_show_urls = 'footnote'
248 |
249 |
250 | # Example configuration for intersphinx: refer to the Python standard library.
251 | intersphinx_mapping = {'http://docs.python.org/': None}
252 |
--------------------------------------------------------------------------------
/docs/examples.rst:
--------------------------------------------------------------------------------
1 | .. _examples:
2 |
3 | Examples
4 | ========
5 |
6 | The following chapter demonstrates a few code examples of how to use the
7 | Tangible library.
8 |
9 |
10 | .. _example_simple_tower:
11 |
12 | A Simple Tower
13 | --------------
14 |
15 | This example describes a simple round tower (:class:`CircleTower1D
16 | `) where the radius of the layers
17 | corresponds to the datapoint. The dataset describes the number of web site
18 | visits on http://blog.dbrgn.ch/ during the month of September 2013. The value
19 | range is normalized to a range between 10 and 50 using a linear scale.
20 |
21 | **Code**
22 |
23 | .. sourcecode:: python
24 |
25 | from tangible import scales
26 | from tangible.shapes.vertical import CircleTower1D
27 | from tangible.backends.openscad import OpenScadBackend
28 |
29 | # Normalize raw data
30 | visits = [53, 69, 86, 92, 81, 76, 37, 36, 62, 76, 72, 67, 55, 61, 54,
31 | 72, 92, 84, 78, 75, 45, 48, 85, 81, 83, 69, 68, 66, 62, 115]
32 | scale = scales.linear([min(visits), max(visits)], [10, 50])
33 | datapoints = map(scale, visits)
34 |
35 | # Create shape
36 | tower = CircleTower1D(datapoints, layer_height=10)
37 |
38 | # Render OpenSCAD code
39 | code = tower.render(backend=OpenScadBackend)
40 | print code
41 |
42 | **Result**
43 |
44 | .. image:: _static/img/examples/tower.png
45 | :width: 300
46 |
47 |
48 | .. _example_multidimensional_data:
49 |
50 | Multi Dimensional Data
51 | ----------------------
52 |
53 | Here we have two dimensional data, represented as two lists of integers. The
54 | first list should be mapped to the angle of the "pie slices", while the second
55 | list should be mapped to the height of each slice. Additionally, we'll add a
56 | center radius to make the model look like a donut, and we'll explode the slices.
57 |
58 | **Code**
59 |
60 | .. sourcecode:: python
61 |
62 | from tangible.shapes.pie import AngleHeightPie2D
63 | from tangible.backends.openscad import OpenScadBackend
64 |
65 | # Data
66 | datapoints = [
67 | [30, 30, 5, 5, 20], # Angle
68 | [18, 23, 20, 15, 10], # Height
69 | ]
70 |
71 | # Create shape
72 | pie = AngleHeightPie2D(datapoints, inner_radius=4, explode=1)
73 |
74 | # Render OpenSCAD code
75 | code = pie.render(backend=OpenScadBackend)
76 | print code
77 |
78 | **Result**
79 |
80 | .. image:: _static/img/examples/angle_pie.png
81 | :width: 300
82 |
83 |
84 | .. _example_csv:
85 |
86 | Reading Data from CSV
87 | ---------------------
88 |
89 | Often the data that you want to visualize is not already available as a Python
90 | datastructure, but in formats like JSON or CSV. Here's a small example where
91 | website visitor data is read from the CSV exported by Google Analytics. Then the
92 | number of visits and the average visit duration are mapped to the distance
93 | between opposing corners of a rhombus tower.
94 |
95 | **Code**
96 |
97 | .. sourcecode:: python
98 |
99 | import csv
100 | from datetime import timedelta
101 | from tangible.shapes.vertical import RhombusTower2D
102 | from tangible.backends.openscad import OpenScadBackend
103 |
104 | # Read data into list
105 | datapoints = [[], []]
106 | with open('analytics-sep-13.csv', 'r') as datafile:
107 | reader = csv.DictReader(datafile)
108 | for row in reader:
109 | datapoints[0].append(int(row['Visits']))
110 | h, m, s = map(int, row['AvgDuration'].split(':'))
111 | duration = timedelta(hours=h, minutes=m, seconds=s)
112 | datapoints[1].append(duration.total_seconds())
113 |
114 | # Create shape
115 | tower = RhombusTower2D(datapoints, layer_height=10)
116 |
117 | # Render OpenSCAD code
118 | code = tower.render(backend=OpenScadBackend)
119 | print code
120 |
121 |
122 | **CSV**
123 |
124 | .. sourcecode:: csv
125 |
126 | Day,Visits,AvgDuration
127 | 9/1/13,53,00:00:51
128 | 9/2/13,69,00:01:01
129 | 9/3/13,86,00:01:24
130 | ...
131 |
132 | **Result**
133 |
134 | .. image:: _static/img/examples/csv.png
135 | :width: 300
136 |
137 |
138 | .. _example_grouped_data:
139 |
140 | Grouped Data
141 | ------------
142 |
143 | Some one dimensional datasets don't work well when visualized directly. An
144 | example would be daily website visitor statistics during a full year, a single
145 | bar graph would be much too wide. But by grouping the data from the :ref:`CSV
146 | example ` into months, a :class:`BarsND
147 | ` graph can be constructed:
148 |
149 | **Code**
150 |
151 | .. sourcecode:: python
152 |
153 | import csv
154 | from itertools import chain
155 | from tangible import scales
156 | from tangible.shapes.bars import BarsND
157 | from tangible.backends.openscad import OpenScadBackend
158 |
159 | # Read data into list
160 | datapoints = [list() for i in range(9)]
161 | with open('analytics-full-13.csv', 'r') as datafile:
162 | reader = csv.DictReader(datafile)
163 | for row in reader:
164 | date = row['Day']
165 | month = int(date.split('/', 1)[0])
166 | visits = int(row['Visits'])
167 | datapoints[month - 1].append(visits)
168 |
169 | # Normalize data
170 | all_datapoints = list(chain.from_iterable(datapoints))
171 | scale = scales.linear([min(all_datapoints), max(all_datapoints)],
172 | [10, 150])
173 | datapoints = map(lambda x: map(scale, x), datapoints)
174 |
175 | # Create shape
176 | bars = BarsND(datapoints, bar_width=7, bar_depth=7)
177 |
178 | # Render OpenSCAD code
179 | code = bars.render(backend=OpenScadBackend)
180 | print code
181 |
182 | **Result**
183 |
184 | .. image:: _static/img/examples/bars_nd.png
185 | :width: 300
186 |
187 |
188 | .. _example_custom_shapes:
189 |
190 | Creating Custom Shapes from AST
191 | -------------------------------
192 |
193 | It's not necessary to rely on the provided shape classes only, you can also
194 | create your own shapes by using the :ref:`AST ` objects directly.
195 |
196 | The easiest and cleanest way to do this, is to create a subclass of the
197 | :class:`BaseShape ` class and to override its
198 | ``_build_ast`` method:
199 |
200 | **Code**
201 |
202 | .. sourcecode:: python
203 |
204 | from tangible.shapes.base import BaseShape
205 | from tangible import ast
206 | from tangible.backends.openscad import OpenScadBackend
207 |
208 | # Create custom shape
209 | class Cogwheel(BaseShape):
210 | def _build_ast(self):
211 | cogs = []
212 | for i in range(18):
213 | cog = ast.Rectangle(2, 2)
214 | translated = ast.Translate(9.5, -1, 0, cog)
215 | rotated = ast.Rotate(i * 30, (0, 0, 1), translated)
216 | cogs.append(rotated)
217 | return ast.Union([ast.Circle(radius=10)] + cogs)
218 |
219 | # Render shape
220 | f = Cogwheel()
221 | code = f.render(backend=OpenScadBackend)
222 | print code
223 |
224 | **Result**
225 |
226 | .. image:: _static/img/examples/cogwheel.png
227 | :width: 300
228 |
--------------------------------------------------------------------------------
/docs/generate_pictures.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 | from __future__ import print_function, division, absolute_import, unicode_literals
4 |
5 | import os
6 | import re
7 | import inspect
8 | import subprocess
9 | import sys
10 |
11 | from tangible import shapes
12 |
13 |
14 | # Get all shape classes
15 | pred = lambda c: inspect.isclass(c) and issubclass(c, shapes.base.Shape)
16 | vertical_classes = inspect.getmembers(shapes.vertical, pred)
17 | bars_classes = inspect.getmembers(shapes.bars, pred)
18 | pie_classes = inspect.getmembers(shapes.pie, pred)
19 | classes = vertical_classes + bars_classes + pie_classes
20 |
21 |
22 | # Get names of shape classes
23 | shape_names = [c[0] for c in classes]
24 |
25 |
26 | # Transform shape names
27 | def convert(name):
28 | s = name
29 | for i in range(4):
30 | s = re.sub('([^_])([A-Z][a-z]+)', r'\1_\2', s)
31 | return re.sub('([0-9]+)([A-Za-z]+)', r'_\1\2', s).lower()
32 | file_names = map(convert, shape_names)
33 |
34 |
35 | # Use -v parameter to output the cmds
36 | verbose = '-v' in sys.argv
37 |
38 |
39 | # Find and execute files
40 | for name in file_names:
41 | print('Processing {}.py: '.format(name), end='')
42 | cmds = [
43 | 'python ../examples/{0}.py > ../examples/{0}.scad',
44 | 'openscad -o _static/img/shapes/{0}.png --imgsize=400,260 ../examples/{0}.scad',
45 | 'rm ../examples/{0}.scad',
46 | ]
47 | if verbose:
48 | print()
49 | for cmd in cmds:
50 | print(' %s' % cmd.format(name))
51 | try:
52 | with open(os.devnull, 'w') as devnull:
53 | for cmd in cmds:
54 | subprocess.check_call(cmd.format(name), shell=True, stderr=devnull)
55 | print('OK')
56 | except:
57 | print('Failed')
58 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | Welcome to Tangible's documentation!
2 | ====================================
3 |
4 | .. image:: https://travis-ci.org/dbrgn/tangible.png
5 | :target: https://travis-ci.org/dbrgn/tangible
6 | :alt: Build Status
7 |
8 | Tangible is a Python library to convert data into tangible 3D models. It
9 | generates code for different backends like OpenSCAD or ImplicitCAD. It is
10 | inspired by projects like OpenSCAD and d3.js.
11 |
12 | The difference from Projects like SolidPython is that Tangible is a modular
13 | system with an intermediate representation of objects that is capable of
14 | generating code for different backends, not just OpenSCAD. Additionally, its
15 | main focus is not general CAD, but printable 3D visualization of data.
16 |
17 | .. image:: https://raw.github.com/dbrgn/tangible/master/example1.jpg
18 | :alt: Example 1
19 |
20 | The workflow is as follows::
21 |
22 | Python code => Intermediate representation (AST) => Programmatic CAD code
23 | => STL file => Slicer => G code => 3D printer => Tangible object
24 |
25 | Source code: `https://github.com/dbrgn/tangible
26 | `_
27 |
28 | Contributions are very welcome! Please open an issue or a pull request `on
29 | Github `_.
30 |
31 |
32 | Table of Contents
33 | =================
34 |
35 | **Using the library**
36 |
37 | .. toctree::
38 | :maxdepth: 2
39 |
40 | installing
41 | usage
42 | examples
43 |
44 | **Reference**
45 |
46 | .. toctree::
47 | :maxdepth: 2
48 |
49 | shapes
50 | scales
51 | utils
52 | ast
53 | backends
54 |
55 |
56 | Indices and tables
57 | ==================
58 |
59 | * :ref:`genindex`
60 | * :ref:`modindex`
61 | * :ref:`search`
62 |
--------------------------------------------------------------------------------
/docs/installing.rst:
--------------------------------------------------------------------------------
1 | .. _installing:
2 |
3 | Installing
4 | ==========
5 |
6 | .. image:: https://pypip.in/d/tangible/badge.png
7 | :target: https://preview-pypi.python.org/project/tangible/
8 | :alt: Downloads
9 |
10 | .. image:: https://pypip.in/v/tangible/badge.png
11 | :target: https://preview-pypi.python.org/project/tangible/
12 | :alt: Latest Version
13 |
14 | You can install Tangible directly via PyPI::
15 |
16 | pip install tangible
17 |
18 | If you want the current development version::
19 |
20 | pip install -e git+https://github.com/dbrgn/tangible#egg=tangible-dev
21 |
--------------------------------------------------------------------------------
/docs/make.bat:
--------------------------------------------------------------------------------
1 | @ECHO OFF
2 |
3 | REM Command file for Sphinx documentation
4 |
5 | if "%SPHINXBUILD%" == "" (
6 | set SPHINXBUILD=sphinx-build
7 | )
8 | set BUILDDIR=_build
9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
10 | set I18NSPHINXOPTS=%SPHINXOPTS% .
11 | if NOT "%PAPER%" == "" (
12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
14 | )
15 |
16 | if "%1" == "" goto help
17 |
18 | if "%1" == "help" (
19 | :help
20 | echo.Please use `make ^` where ^ is one of
21 | echo. html to make standalone HTML files
22 | echo. dirhtml to make HTML files named index.html in directories
23 | echo. singlehtml to make a single large HTML file
24 | echo. pickle to make pickle files
25 | echo. json to make JSON files
26 | echo. htmlhelp to make HTML files and a HTML help project
27 | echo. qthelp to make HTML files and a qthelp project
28 | echo. devhelp to make HTML files and a Devhelp project
29 | echo. epub to make an epub
30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
31 | echo. text to make text files
32 | echo. man to make manual pages
33 | echo. texinfo to make Texinfo files
34 | echo. gettext to make PO message catalogs
35 | echo. changes to make an overview over all changed/added/deprecated items
36 | echo. linkcheck to check all external links for integrity
37 | echo. doctest to run all doctests embedded in the documentation if enabled
38 | goto end
39 | )
40 |
41 | if "%1" == "clean" (
42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
43 | del /q /s %BUILDDIR%\*
44 | goto end
45 | )
46 |
47 | if "%1" == "html" (
48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
49 | if errorlevel 1 exit /b 1
50 | echo.
51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html.
52 | goto end
53 | )
54 |
55 | if "%1" == "dirhtml" (
56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
57 | if errorlevel 1 exit /b 1
58 | echo.
59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
60 | goto end
61 | )
62 |
63 | if "%1" == "singlehtml" (
64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
65 | if errorlevel 1 exit /b 1
66 | echo.
67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
68 | goto end
69 | )
70 |
71 | if "%1" == "pickle" (
72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
73 | if errorlevel 1 exit /b 1
74 | echo.
75 | echo.Build finished; now you can process the pickle files.
76 | goto end
77 | )
78 |
79 | if "%1" == "json" (
80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
81 | if errorlevel 1 exit /b 1
82 | echo.
83 | echo.Build finished; now you can process the JSON files.
84 | goto end
85 | )
86 |
87 | if "%1" == "htmlhelp" (
88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
89 | if errorlevel 1 exit /b 1
90 | echo.
91 | echo.Build finished; now you can run HTML Help Workshop with the ^
92 | .hhp project file in %BUILDDIR%/htmlhelp.
93 | goto end
94 | )
95 |
96 | if "%1" == "qthelp" (
97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
98 | if errorlevel 1 exit /b 1
99 | echo.
100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^
101 | .qhcp project file in %BUILDDIR%/qthelp, like this:
102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Tangible.qhcp
103 | echo.To view the help file:
104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Tangible.ghc
105 | goto end
106 | )
107 |
108 | if "%1" == "devhelp" (
109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
110 | if errorlevel 1 exit /b 1
111 | echo.
112 | echo.Build finished.
113 | goto end
114 | )
115 |
116 | if "%1" == "epub" (
117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
118 | if errorlevel 1 exit /b 1
119 | echo.
120 | echo.Build finished. The epub file is in %BUILDDIR%/epub.
121 | goto end
122 | )
123 |
124 | if "%1" == "latex" (
125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
126 | if errorlevel 1 exit /b 1
127 | echo.
128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
129 | goto end
130 | )
131 |
132 | if "%1" == "text" (
133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
134 | if errorlevel 1 exit /b 1
135 | echo.
136 | echo.Build finished. The text files are in %BUILDDIR%/text.
137 | goto end
138 | )
139 |
140 | if "%1" == "man" (
141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
142 | if errorlevel 1 exit /b 1
143 | echo.
144 | echo.Build finished. The manual pages are in %BUILDDIR%/man.
145 | goto end
146 | )
147 |
148 | if "%1" == "texinfo" (
149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
150 | if errorlevel 1 exit /b 1
151 | echo.
152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
153 | goto end
154 | )
155 |
156 | if "%1" == "gettext" (
157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
158 | if errorlevel 1 exit /b 1
159 | echo.
160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
161 | goto end
162 | )
163 |
164 | if "%1" == "changes" (
165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
166 | if errorlevel 1 exit /b 1
167 | echo.
168 | echo.The overview file is in %BUILDDIR%/changes.
169 | goto end
170 | )
171 |
172 | if "%1" == "linkcheck" (
173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
174 | if errorlevel 1 exit /b 1
175 | echo.
176 | echo.Link check complete; look for any errors in the above output ^
177 | or in %BUILDDIR%/linkcheck/output.txt.
178 | goto end
179 | )
180 |
181 | if "%1" == "doctest" (
182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
183 | if errorlevel 1 exit /b 1
184 | echo.
185 | echo.Testing of doctests in the sources finished, look at the ^
186 | results in %BUILDDIR%/doctest/output.txt.
187 | goto end
188 | )
189 |
190 | :end
191 |
--------------------------------------------------------------------------------
/docs/scales.rst:
--------------------------------------------------------------------------------
1 | Scales
2 | ======
3 |
4 | .. automodule:: tangible.scales
5 | :members:
6 |
--------------------------------------------------------------------------------
/docs/shapes.rst:
--------------------------------------------------------------------------------
1 | .. _shapes:
2 |
3 | Shapes
4 | ======
5 |
6 | Shapes make it easier to generate specific models from data, without having to
7 | manipulate AST objects directly.
8 |
9 | Shape Types
10 | -----------
11 |
12 | All shapes are grouped into categories. Click on one of the categories to see a
13 | detailed list of shapes.
14 |
15 | .. toctree::
16 | :maxdepth: 1
17 |
18 | shapes/bars
19 | shapes/vertical
20 | shapes/pie
21 |
22 | Base Classes
23 | ------------
24 |
25 | .. inheritance-diagram::
26 | tangible.shapes.base.BaseShape
27 | tangible.shapes.base.Shape
28 | tangible.shapes.bars.BarsShape
29 | tangible.shapes.vertical.VerticalShape
30 | tangible.shapes.pie.PieShape
31 | :parts: 2
32 |
33 | .. autoclass:: tangible.shapes.base.BaseShape
34 | :members:
35 |
36 | .. autoclass:: tangible.shapes.base.Shape
37 | :members:
38 |
39 | .. autoclass:: tangible.shapes.bars.BarsShape
40 | :members:
41 | :noindex:
42 |
43 | .. autoclass:: tangible.shapes.vertical.VerticalShape
44 | :members:
45 | :noindex:
46 |
47 | .. autoclass:: tangible.shapes.pie.PieShape
48 | :members:
49 | :noindex:
50 |
51 |
52 | Inheritance Diagram
53 | -------------------
54 |
55 | .. inheritance-diagram::
56 | tangible.shapes.base
57 | tangible.shapes.bars
58 | tangible.shapes.vertical
59 | tangible.shapes.pie
60 | :parts: 2
61 |
--------------------------------------------------------------------------------
/docs/shapes/bars.rst:
--------------------------------------------------------------------------------
1 | .. _bar_shapes:
2 |
3 | Bar Shapes
4 | ==========
5 |
6 | Bar shapes consist of several bars that start on ``z=0`` and have a height
7 | depending on the corresponding datapoint. They can be aligned in rows, and rows
8 | can be combined to create 3D bar graphs.
9 |
10 | Class Hierarchy
11 | ---------------
12 |
13 | .. inheritance-diagram::
14 | tangible.shapes.bars.Bars1D
15 | tangible.shapes.bars.BarsND
16 | :parts: 2
17 |
18 | Base Class
19 | ----------
20 |
21 | .. autoclass:: tangible.shapes.bars.BarsShape
22 | :members:
23 |
24 | Shape Classes
25 | -------------
26 |
27 | .. autoclass:: tangible.shapes.bars.Bars1D
28 | :members:
29 | .. image:: ../_static/img/shapes/bars_1d.png
30 |
31 | .. autoclass:: tangible.shapes.bars.BarsND
32 | :members:
33 | .. image:: ../_static/img/shapes/barsnd.png
34 |
35 |
--------------------------------------------------------------------------------
/docs/shapes/pie.rst:
--------------------------------------------------------------------------------
1 | .. _pie_shapes:
2 |
3 | Pie Shapes
4 | ==========
5 |
6 | A pie shape can represent data as angle, height or radius of the corresponding
7 | slice. It is possible to define an inner radius (-> donut) and to explode the
8 | slices.
9 |
10 |
11 | Class Hierarchy
12 | ---------------
13 |
14 | .. inheritance-diagram::
15 | tangible.shapes.pie.AnglePie1D
16 | tangible.shapes.pie.RadiusPie1D
17 | tangible.shapes.pie.HeightPie1D
18 | tangible.shapes.pie.AngleRadiusPie2D
19 | tangible.shapes.pie.AngleHeightPie2D
20 | tangible.shapes.pie.RadiusHeightPie2D
21 | tangible.shapes.pie.AngleRadiusHeightPie3D
22 | :parts: 2
23 |
24 | Base Class
25 | ----------
26 |
27 | .. autoclass:: tangible.shapes.pie.PieShape
28 | :members:
29 |
30 | Shape Classes
31 | -------------
32 |
33 | .. autoclass:: tangible.shapes.pie.AnglePie1D
34 | :members:
35 | .. image:: ../_static/img/shapes/angle_pie_1d.png
36 |
37 | .. autoclass:: tangible.shapes.pie.RadiusPie1D
38 | :members:
39 | .. image:: ../_static/img/shapes/radius_pie_1d.png
40 |
41 | .. autoclass:: tangible.shapes.pie.HeightPie1D
42 | :members:
43 | .. image:: ../_static/img/shapes/height_pie_1d.png
44 |
45 | .. autoclass:: tangible.shapes.pie.AngleRadiusPie2D
46 | :members:
47 | .. image:: ../_static/img/shapes/angle_radius_pie_2d.png
48 |
49 | .. autoclass:: tangible.shapes.pie.AngleHeightPie2D
50 | :members:
51 | .. image:: ../_static/img/shapes/angle_height_pie_2d.png
52 |
53 | .. autoclass:: tangible.shapes.pie.RadiusHeightPie2D
54 | :members:
55 | .. image:: ../_static/img/shapes/radius_height_pie_2d.png
56 |
57 | .. autoclass:: tangible.shapes.pie.AngleRadiusHeightPie3D
58 | :members:
59 | .. image:: ../_static/img/shapes/angle_radius_height_pie_3d.png
60 |
--------------------------------------------------------------------------------
/docs/shapes/vertical.rst:
--------------------------------------------------------------------------------
1 | .. _vertical_shapes:
2 |
3 | Vertical Shapes
4 | ===============
5 |
6 | A vertical shape is a shape with layers stacked on top of each other, with a
7 | fixed layer height, for example a round tower where the radius corresponds to
8 | the datapoint.
9 |
10 | Class Hierarchy
11 | ---------------
12 |
13 | .. inheritance-diagram::
14 | tangible.shapes.vertical.CircleTower1D
15 | tangible.shapes.vertical.SquareTower1D
16 | tangible.shapes.vertical.RectangleTower2D
17 | tangible.shapes.vertical.RhombusTower2D
18 | tangible.shapes.vertical.QuadrilateralTower4D
19 | :parts: 2
20 |
21 | Base Class
22 | ----------
23 |
24 | .. autoclass:: tangible.shapes.vertical.VerticalShape
25 | :members:
26 |
27 | Shape Classes
28 | -------------
29 |
30 | .. autoclass:: tangible.shapes.vertical.CircleTower1D
31 | :members:
32 | .. image:: ../_static/img/shapes/circle_tower_1d.png
33 |
34 | .. autoclass:: tangible.shapes.vertical.SquareTower1D
35 | :members:
36 | .. image:: ../_static/img/shapes/square_tower_1d.png
37 |
38 | .. autoclass:: tangible.shapes.vertical.RectangleTower2D
39 | :members:
40 | .. image:: ../_static/img/shapes/rectangle_tower_2d.png
41 |
42 | .. autoclass:: tangible.shapes.vertical.RhombusTower2D
43 | :members:
44 | .. image:: ../_static/img/shapes/rhombus_tower_2d.png
45 |
46 | .. autoclass:: tangible.shapes.vertical.QuadrilateralTower4D
47 | :members:
48 | .. image:: ../_static/img/shapes/quadrilateral_tower_4d.png
49 |
--------------------------------------------------------------------------------
/docs/usage.rst:
--------------------------------------------------------------------------------
1 | Usage
2 | =====
3 |
4 | Tangible was designed to be very straightforward to use. Common data
5 | visualizations should be possible with just a few lines of code.
6 |
7 | Visualizing data with Tangible consists of three steps: Preprocessing the data,
8 | creating a shape instance and finally rendering the code using the desired
9 | backend.
10 |
11 | Preprocessing Data
12 | ------------------
13 |
14 | Many times the data is not yet in the right form for visualization. Let's say
15 | that you have air temperature measurements for every hour during an entire day.
16 | The temperature range is between 8°C during the night and 22°C during the day.
17 |
18 | .. sourcecode:: python
19 |
20 | >>> temperatures = [
21 | >>> 10, 9, 8, 8, 9, 8, 9, 10, 12, 15, 17, 19,
22 | >>> 20, 22, 22, 21, 20, 17, 14, 12, 11, 10, 9, 10
23 | >>> ]
24 |
25 | To visualize the data, you want to create a round tower where the radius of a
26 | slice corresponds to a temperature measurement. But the temperatures are not
27 | well suited to be used directly as millimeter values. Therefore you want to
28 | linearly transform the range 8–22 (°C) to the range 10–40 (mm).
29 |
30 | Tangible provides helper functions for this called *scales*. First a linear
31 | scale needs to be constructed:
32 |
33 | .. sourcecode:: python
34 |
35 | >>> from tangible import scales
36 | >>> scale = scales.linear(domain=[8, 22], codomain=[10, 40])
37 |
38 | The returned object is the actual scaling function. It can be used directly:
39 |
40 | .. sourcecode:: python
41 |
42 | >>> scale(8)
43 | 10.0
44 | >>> scale(15)
45 | 25.0
46 | >>> scale(22)
47 | 40.0
48 |
49 | ...or it can be used in combination with Python's ``map()`` function:
50 |
51 | .. sourcecode:: python
52 |
53 | >>> radii = map(scale, temperatures)
54 | >>> radii
55 | [14.285714285714285, 12.142857142857142, 10.0, 10.0, ...]
56 |
57 | Now the data is ready to be visualized. There are also several other functions
58 | to preprocess data, for example to group or aggregate datapoints. For more
59 | information, take a look at the :ref:`utils` docs.
60 |
61 | Creating a Shape Instance
62 | -------------------------
63 |
64 | Tangible provides many predefined shapes that can be used directly. Currently
65 | there are three types of shapes: :ref:`Vertical shapes `,
66 | :ref:`bar shapes ` and :ref:`pie shapes `.
67 |
68 | For the temperature tower, you need the :class:`CircleTower1D
69 | ` shape from the
70 | :mod:`tangible.shapes.vertical` module. The class requires two arguments:
71 | The data list as well as the height of each layer.
72 |
73 | .. sourcecode:: python
74 |
75 | >>> from tangible.shapes.vertical import CircleTower1D
76 | >>> tower = CircleTower1D(data=radii, layer_height=2)
77 |
78 | An overview over all shape classes can be found in the :ref:`shape docs
79 | `.
80 |
81 | Rendering the Code
82 | ------------------
83 |
84 | Now the shape is ready to be rendered. First, choose the :ref:`desired backend
85 | `. Right now, the only available backend is the :ref:`OpenSCAD backend
86 | `.
87 |
88 | .. sourcecode:: python
89 |
90 | >>> from tangible.backends.openscad import OpenScadBackend
91 |
92 | Next, render the shape using this backend. For convenience, we write the
93 | resulting code directly into a file.
94 |
95 | .. sourcecode:: python
96 |
97 | >>> with open('tower.scad', 'w') as f:
98 | ... code = tower.render(backend=openscad.OpenScadBackend)
99 | ... f.write(code)
100 |
101 | The OpenSCAD code can now be rendered on the command line (or alternatively from
102 | the GUI tool) into an image for previewing or into an STL file for printing::
103 |
104 | $ openscad -o tower.png --render --imgsize=512,512 tower.scad
105 | CGAL Cache insert: cylinder($fn=0,$fa=12,$fs=2,h=5,r1=14.28)
106 | CGAL Cache insert: cylinder($fn=0,$fa=12,$fs=2,h=5,r1=12.14)
107 | ...
108 | $ openscad -o tower.stl --render tower.scad
109 | CGAL Cache insert: cylinder($fn=0,$fa=12,$fs=2,h=5,r1=14.28)
110 | CGAL Cache insert: cylinder($fn=0,$fa=12,$fs=2,h=5,r1=12.14)
111 | ...
112 |
113 | The result:
114 |
115 | .. image:: _static/img/usage_tower.png
116 | :width: 300
117 | :alt: 3D visualization of a temperature range
118 |
119 | A few more usage examples are available in the :ref:`examples` section.
120 |
--------------------------------------------------------------------------------
/docs/utils.rst:
--------------------------------------------------------------------------------
1 | .. _utils:
2 |
3 | Utils
4 | =====
5 |
6 | .. automodule:: tangible.utils
7 | :members:
8 |
--------------------------------------------------------------------------------
/example1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/example1.jpg
--------------------------------------------------------------------------------
/examples/analytics-full-13.csv:
--------------------------------------------------------------------------------
1 | Day,Visits
2 | 1/1/13,19
3 | 1/2/13,24
4 | 1/3/13,31
5 | 1/4/13,19
6 | 1/5/13,18
7 | 1/6/13,29
8 | 1/7/13,31
9 | 1/8/13,47
10 | 1/9/13,24
11 | 1/10/13,38
12 | 1/11/13,43
13 | 1/12/13,22
14 | 1/13/13,28
15 | 1/14/13,51
16 | 1/15/13,49
17 | 1/16/13,49
18 | 1/17/13,36
19 | 1/18/13,37
20 | 1/19/13,33
21 | 1/20/13,20
22 | 1/21/13,26
23 | 1/22/13,30
24 | 1/23/13,31
25 | 1/24/13,24
26 | 1/25/13,27
27 | 1/26/13,22
28 | 1/27/13,22
29 | 1/28/13,36
30 | 1/29/13,32
31 | 1/30/13,26
32 | 1/31/13,41
33 | 2/1/13,26
34 | 2/2/13,17
35 | 2/3/13,14
36 | 2/4/13,22
37 | 2/5/13,19
38 | 2/6/13,36
39 | 2/7/13,38
40 | 2/8/13,34
41 | 2/9/13,20
42 | 2/10/13,25
43 | 2/11/13,25
44 | 2/12/13,30
45 | 2/13/13,35
46 | 2/14/13,28
47 | 2/15/13,16
48 | 2/16/13,23
49 | 2/17/13,14
50 | 2/18/13,29
51 | 2/19/13,28
52 | 2/20/13,28
53 | 2/21/13,24
54 | 2/22/13,22
55 | 2/23/13,16
56 | 2/24/13,17
57 | 2/25/13,30
58 | 2/26/13,35
59 | 2/27/13,29
60 | 2/28/13,28
61 | 3/1/13,33
62 | 3/2/13,40
63 | 3/3/13,24
64 | 3/4/13,21
65 | 3/5/13,29
66 | 3/6/13,34
67 | 3/7/13,31
68 | 3/8/13,49
69 | 3/9/13,22
70 | 3/10/13,14
71 | 3/11/13,30
72 | 3/12/13,41
73 | 3/13/13,25
74 | 3/14/13,14
75 | 3/15/13,26
76 | 3/16/13,63
77 | 3/17/13,50
78 | 3/18/13,51
79 | 3/19/13,45
80 | 3/20/13,64
81 | 3/21/13,53
82 | 3/22/13,48
83 | 3/23/13,49
84 | 3/24/13,30
85 | 3/25/13,52
86 | 3/26/13,82
87 | 3/27/13,60
88 | 3/28/13,62
89 | 3/29/13,38
90 | 3/30/13,35
91 | 3/31/13,52
92 | 4/1/13,42
93 | 4/2/13,56
94 | 4/3/13,57
95 | 4/4/13,58
96 | 4/5/13,54
97 | 4/6/13,32
98 | 4/7/13,39
99 | 4/8/13,71
100 | 4/9/13,55
101 | 4/10/13,61
102 | 4/11/13,47
103 | 4/12/13,65
104 | 4/13/13,38
105 | 4/14/13,37
106 | 4/15/13,66
107 | 4/16/13,66
108 | 4/17/13,46
109 | 4/18/13,64
110 | 4/19/13,56
111 | 4/20/13,39
112 | 4/21/13,34
113 | 4/22/13,56
114 | 4/23/13,45
115 | 4/24/13,69
116 | 4/25/13,70
117 | 4/26/13,298
118 | 4/27/13,146
119 | 4/28/13,85
120 | 4/29/13,80
121 | 4/30/13,73
122 | 5/1/13,48
123 | 5/2/13,84
124 | 5/3/13,51
125 | 5/4/13,42
126 | 5/5/13,54
127 | 5/6/13,78
128 | 5/7/13,68
129 | 5/8/13,62
130 | 5/9/13,63
131 | 5/10/13,52
132 | 5/11/13,32
133 | 5/12/13,45
134 | 5/13/13,53
135 | 5/14/13,69
136 | 5/15/13,62
137 | 5/16/13,78
138 | 5/17/13,84
139 | 5/18/13,27
140 | 5/19/13,36
141 | 5/20/13,63
142 | 5/21/13,68
143 | 5/22/13,35
144 | 5/23/13,71
145 | 5/24/13,76
146 | 5/25/13,42
147 | 5/26/13,47
148 | 5/27/13,83
149 | 5/28/13,67
150 | 5/29/13,76
151 | 5/30/13,63
152 | 5/31/13,74
153 | 6/1/13,46
154 | 6/2/13,43
155 | 6/3/13,80
156 | 6/4/13,77
157 | 6/5/13,61
158 | 6/6/13,52
159 | 6/7/13,47
160 | 6/8/13,31
161 | 6/9/13,29
162 | 6/10/13,67
163 | 6/11/13,53
164 | 6/12/13,58
165 | 6/13/13,58
166 | 6/14/13,61
167 | 6/15/13,46
168 | 6/16/13,28
169 | 6/17/13,54
170 | 6/18/13,103
171 | 6/19/13,111
172 | 6/20/13,47
173 | 6/21/13,61
174 | 6/22/13,51
175 | 6/23/13,55
176 | 6/24/13,84
177 | 6/25/13,66
178 | 6/26/13,72
179 | 6/27/13,60
180 | 6/28/13,53
181 | 6/29/13,33
182 | 6/30/13,49
183 | 7/1/13,64
184 | 7/2/13,56
185 | 7/3/13,64
186 | 7/4/13,67
187 | 7/5/13,70
188 | 7/6/13,41
189 | 7/7/13,36
190 | 7/8/13,84
191 | 7/9/13,86
192 | 7/10/13,78
193 | 7/11/13,59
194 | 7/12/13,66
195 | 7/13/13,35
196 | 7/14/13,40
197 | 7/15/13,66
198 | 7/16/13,71
199 | 7/17/13,64
200 | 7/18/13,56
201 | 7/19/13,41
202 | 7/20/13,33
203 | 7/21/13,59
204 | 7/22/13,79
205 | 7/23/13,79
206 | 7/24/13,70
207 | 7/25/13,61
208 | 7/26/13,50
209 | 7/27/13,31
210 | 7/28/13,32
211 | 7/29/13,66
212 | 7/30/13,66
213 | 7/31/13,56
214 | 8/1/13,81
215 | 8/2/13,70
216 | 8/3/13,54
217 | 8/4/13,45
218 | 8/5/13,82
219 | 8/6/13,79
220 | 8/7/13,83
221 | 8/8/13,66
222 | 8/9/13,63
223 | 8/10/13,39
224 | 8/11/13,63
225 | 8/12/13,80
226 | 8/13/13,92
227 | 8/14/13,99
228 | 8/15/13,74
229 | 8/16/13,66
230 | 8/17/13,39
231 | 8/18/13,66
232 | 8/19/13,79
233 | 8/20/13,68
234 | 8/21/13,75
235 | 8/22/13,90
236 | 8/23/13,82
237 | 8/24/13,57
238 | 8/25/13,52
239 | 8/26/13,82
240 | 8/27/13,72
241 | 8/28/13,67
242 | 8/29/13,91
243 | 8/30/13,74
244 | 8/31/13,46
245 | 9/1/13,53
246 | 9/2/13,69
247 | 9/3/13,86
248 | 9/4/13,92
249 | 9/5/13,81
250 | 9/6/13,76
251 | 9/7/13,37
252 | 9/8/13,36
253 | 9/9/13,62
254 | 9/10/13,76
255 | 9/11/13,72
256 | 9/12/13,67
257 | 9/13/13,55
258 | 9/14/13,61
259 | 9/15/13,54
260 | 9/16/13,72
261 | 9/17/13,92
262 | 9/18/13,84
263 | 9/19/13,78
264 | 9/20/13,75
265 | 9/21/13,45
266 | 9/22/13,48
267 | 9/23/13,85
268 | 9/24/13,81
269 | 9/25/13,83
270 | 9/26/13,69
271 | 9/27/13,68
272 | 9/28/13,66
273 | 9/29/13,62
274 | 9/30/13,115
275 |
--------------------------------------------------------------------------------
/examples/analytics-sep-13.csv:
--------------------------------------------------------------------------------
1 | Day,Visits,AvgDuration
2 | 9/1/13,53,00:00:51
3 | 9/2/13,69,00:01:01
4 | 9/3/13,86,00:01:24
5 | 9/4/13,92,00:01:32
6 | 9/5/13,81,00:00:26
7 | 9/6/13,76,00:00:37
8 | 9/7/13,37,00:01:04
9 | 9/8/13,36,00:03:04
10 | 9/9/13,62,00:00:44
11 | 9/10/13,76,00:01:04
12 | 9/11/13,72,00:00:59
13 | 9/12/13,67,00:00:02
14 | 9/13/13,55,00:00:13
15 | 9/14/13,61,00:00:37
16 | 9/15/13,54,00:00:54
17 | 9/16/13,72,00:01:09
18 | 9/17/13,92,00:00:51
19 | 9/18/13,84,00:00:51
20 | 9/19/13,78,00:01:39
21 | 9/20/13,75,00:00:27
22 | 9/21/13,45,00:01:11
23 | 9/22/13,48,00:01:01
24 | 9/23/13,85,00:01:11
25 | 9/24/13,81,00:01:31
26 | 9/25/13,83,00:01:22
27 | 9/26/13,69,00:00:07
28 | 9/27/13,68,00:00:55
29 | 9/28/13,66,00:00:20
30 | 9/29/13,62,00:01:10
31 | 9/30/13,115,00:01:12
32 |
--------------------------------------------------------------------------------
/examples/angle_height_pie_2d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import AngleHeightPie2D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [
8 | [30, 30, 5, 5, 20],
9 | [18, 23, 15, 20, 10],
10 | ]
11 | pie = AngleHeightPie2D(datapoints, inner_radius=2, explode=1)
12 | code = pie.render(backend=OpenScadBackend)
13 | print(code)
14 |
--------------------------------------------------------------------------------
/examples/angle_pie_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import AnglePie1D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [10, 20, 30, 40, 50]
8 | pie = AnglePie1D(datapoints, height=2, outer_radius=10, inner_radius=0, explode=0.2)
9 | code = pie.render(backend=OpenScadBackend)
10 | print(code)
11 |
--------------------------------------------------------------------------------
/examples/angle_radius_height_pie_3d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import AngleRadiusHeightPie3D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [
8 | [30, 30, 5, 5, 20], # Angle
9 | [18, 23, 15, 20, 10], # Radius
10 | [10, 20, 30, 25, 40], # Height
11 | ]
12 | pie = AngleRadiusHeightPie3D(datapoints)
13 | code = pie.render(backend=OpenScadBackend)
14 | print(code)
15 |
--------------------------------------------------------------------------------
/examples/angle_radius_pie_2d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import AngleRadiusPie2D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [
8 | [30, 30, 5, 5, 20],
9 | [18, 23, 15, 20, 10],
10 | ]
11 | pie = AngleRadiusPie2D(datapoints, height=2, inner_radius=2, explode=0.4)
12 | code = pie.render(backend=OpenScadBackend)
13 | print(code)
14 |
--------------------------------------------------------------------------------
/examples/bars_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import csv
5 |
6 | from tangible import scales
7 | from tangible.shapes.bars import Bars1D
8 | from tangible.backends.openscad import OpenScadBackend
9 |
10 |
11 | # Read data into list
12 | datapoints = []
13 | with open('analytics-sep-13.csv', 'r') as datafile:
14 | reader = csv.DictReader(datafile)
15 | for row in reader:
16 | visits = int(row['Visits'])
17 | datapoints.append(visits)
18 |
19 |
20 | # Normalize data
21 | scale = scales.linear([min(datapoints), max(datapoints)], [10, 80])
22 | datapoints = [scale(p) for p in datapoints]
23 |
24 |
25 | # Create shape
26 | bars1d = Bars1D(datapoints, bar_width=10, bar_depth=10)
27 |
28 | code = bars1d.render(backend=OpenScadBackend)
29 | print(code)
30 |
--------------------------------------------------------------------------------
/examples/barsnd.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import csv
5 | from itertools import chain
6 |
7 | from tangible import scales
8 | from tangible.shapes.bars import BarsND
9 | from tangible.backends.openscad import OpenScadBackend
10 |
11 |
12 | # Read data into list
13 | datapoints = [list() for i in range(9)]
14 | with open('analytics-full-13.csv', 'r') as datafile:
15 | reader = csv.DictReader(datafile)
16 | for row in reader:
17 | date = row['Day']
18 | month = int(date.split('/', 1)[0])
19 | visits = int(row['Visits'])
20 | datapoints[month - 1].append(visits)
21 |
22 |
23 | # Normalize data
24 | all_datapoints = list(chain.from_iterable(datapoints))
25 | scale = scales.linear([min(all_datapoints), max(all_datapoints)], [10, 150])
26 | datapoints = [[scale(i) for i in x] for x in datapoints]
27 |
28 | # Create shape
29 | bars = BarsND(datapoints, bar_width=7, bar_depth=7, center_layers=False)
30 |
31 | code = bars.render(backend=OpenScadBackend)
32 | print(code)
33 |
--------------------------------------------------------------------------------
/examples/circle_tower_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import csv
5 |
6 | from tangible import scales
7 | from tangible.shapes.vertical import CircleTower1D
8 | from tangible.backends.openscad import OpenScadBackend
9 |
10 |
11 | # Read data into list
12 | datapoints = []
13 | with open('analytics-sep-13.csv', 'r') as datafile:
14 | reader = csv.DictReader(datafile)
15 | for row in reader:
16 | visits = int(row['Visits'])
17 | datapoints.append(visits)
18 |
19 |
20 | # Normalize data
21 | scale = scales.linear([min(datapoints), max(datapoints)], [10, 50])
22 | datapoints = [scale(p) for p in datapoints]
23 |
24 | # Create shape
25 | tower = CircleTower1D(datapoints, layer_height=10)
26 |
27 | code = tower.render(backend=OpenScadBackend)
28 | print(code)
29 |
--------------------------------------------------------------------------------
/examples/height_pie_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import HeightPie1D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [10, 20, 30, 40, 50]
8 | pie = HeightPie1D(datapoints, outer_radius=10, inner_radius=6)
9 | code = pie.render(backend=OpenScadBackend)
10 | print(code)
11 |
--------------------------------------------------------------------------------
/examples/quadrilateral_tower_4d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.vertical import QuadrilateralTower4D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 |
8 | datapoints = [
9 | [10, 5, 10, 5, 10],
10 | [20, 16, 20, 16, 20],
11 | [5, 10, 5, 10, 5],
12 | [6, 8, 6, 8, 6],
13 | ]
14 |
15 |
16 | # Create shape
17 | tower = QuadrilateralTower4D(datapoints, layer_height=10)
18 |
19 | code = tower.render(backend=OpenScadBackend)
20 | print(code)
21 |
--------------------------------------------------------------------------------
/examples/radius_height_pie_2d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import RadiusHeightPie2D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [
8 | [18, 23, 15, 20, 10],
9 | [10, 20, 30, 25, 40],
10 | ]
11 | pie = RadiusHeightPie2D(datapoints)
12 | code = pie.render(backend=OpenScadBackend)
13 | print(code)
14 |
--------------------------------------------------------------------------------
/examples/radius_pie_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.pie import RadiusPie1D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 | datapoints = [18, 23, 15, 20, 10]
8 | pie = RadiusPie1D(datapoints, height=10, inner_radius=3)
9 | code = pie.render(backend=OpenScadBackend)
10 | print(code)
11 |
--------------------------------------------------------------------------------
/examples/rectangle_tower_2d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.vertical import RectangleTower2D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 |
8 | datapoints = [
9 | [6, 10, 6],
10 | [6, 6, 22],
11 | ]
12 |
13 |
14 | # Create shape
15 | tower = RectangleTower2D(datapoints, layer_height=10)
16 |
17 | code = tower.render(backend=OpenScadBackend)
18 | print(code)
19 |
--------------------------------------------------------------------------------
/examples/rhombus_tower_2d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.vertical import RhombusTower2D
5 | from tangible.backends.openscad import OpenScadBackend
6 |
7 |
8 | datapoints = [
9 | [6, 10, 6],
10 | [6, 6, 22],
11 | ]
12 |
13 |
14 | # Create shape
15 | tower = RhombusTower2D(datapoints, layer_height=10)
16 |
17 | code = tower.render(backend=OpenScadBackend)
18 | print(code)
19 |
--------------------------------------------------------------------------------
/examples/square_tower_1d.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import csv
5 |
6 | from tangible import scales
7 | from tangible.shapes.vertical import SquareTower1D
8 | from tangible.backends.openscad import OpenScadBackend
9 |
10 |
11 | # Read data into list
12 | datapoints = []
13 | with open('analytics-sep-13.csv', 'r') as datafile:
14 | reader = csv.DictReader(datafile)
15 | for row in reader:
16 | visits = int(row['Visits'])
17 | datapoints.append(visits)
18 |
19 |
20 | # Normalize data
21 | scale = scales.linear([min(datapoints), max(datapoints)], [10, 50])
22 | datapoints = [scale(p) for p in datapoints]
23 |
24 |
25 | # Create shape
26 | tower = SquareTower1D(datapoints, layer_height=10)
27 |
28 | code = tower.render(backend=OpenScadBackend)
29 | print(code)
30 |
--------------------------------------------------------------------------------
/examples/test_preamble.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.base import BaseShape
5 | from tangible import ast
6 | from tangible.backends.openscad import OpenScadBackend
7 |
8 |
9 | class Circle(BaseShape):
10 | def _build_ast(self):
11 | return ast.Difference([
12 | ast.CircleSector(10, 270),
13 | ast.CircleSector(10, 45),
14 | ])
15 |
16 |
17 | q = Circle()
18 | code = q.render(backend=OpenScadBackend)
19 | print(code)
20 |
--------------------------------------------------------------------------------
/examples/test_quads.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from tangible.shapes.base import BaseShape
5 | from tangible import ast
6 | from tangible.backends.openscad import OpenScadBackend
7 |
8 |
9 | class Quads(BaseShape):
10 |
11 | def _build_ast(self):
12 | points = [
13 | (0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0),
14 | (0, 0, 10), (0, 8, 10), (8, 8, 10), (8, 0, 10),
15 | ]
16 | quads = [
17 | (0, 3, 2, 1),
18 | (0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7),
19 | (4, 5, 6, 7),
20 | ]
21 | return ast.Polyhedron(points=points, quads=quads)
22 |
23 |
24 | q = Quads()
25 | code = q.render(backend=OpenScadBackend)
26 | print(code)
27 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | addopts = --cov tangible --cov-config .coveragerc --pep8 --tb=short
3 | python_files = test_*.py
4 | pep8ignore =
5 | *.py E126 E127 E128 E266 E731
6 | tangible/backends/*.py E721
7 | setup.py ALL
8 | docs/* ALL
9 | pep8maxlinelength = 99
10 |
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
1 | # Testing
2 | pytest<4
3 | pytest-cov<3
4 | pytest-pep8<2
5 |
6 | # Docs
7 | Sphinx==1.2b3
8 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | from setuptools import setup
4 | import tangible
5 |
6 | readme = open('README.rst').read()
7 |
8 | setup(name='tangible',
9 | version=tangible.__VERSION__,
10 | description='A Python library to convert data into tangible 3D models.',
11 | long_description=readme,
12 | author=tangible.__AUTHOR__,
13 | author_email=tangible.__AUTHOR_EMAIL__,
14 | url='https://github.com/dbrgn/tangible',
15 | license='LGPLv3',
16 | keywords='tangible visualization 3d printing',
17 | packages=['tangible', 'tangible.backends', 'tangible.shapes'],
18 | platforms=['any'],
19 | classifiers=[
20 | 'Development Status :: 4 - Beta',
21 | 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)',
22 | 'Operating System :: OS Independent',
23 | 'Programming Language :: Python',
24 | 'Programming Language :: Python :: 2',
25 | 'Programming Language :: Python :: 2.7',
26 | 'Programming Language :: Python :: 3',
27 | 'Programming Language :: Python :: 3.4',
28 | 'Programming Language :: Python :: 3.5',
29 | 'Programming Language :: Python :: 3.6',
30 | 'Programming Language :: Python :: 3.7',
31 | 'Topic :: Artistic Software',
32 | 'Topic :: Multimedia :: Graphics :: 3D Modeling',
33 | 'Topic :: Scientific/Engineering :: Visualization',
34 | 'Topic :: Software Development :: Libraries :: Python Modules',
35 | ],
36 | )
37 |
--------------------------------------------------------------------------------
/tangible/__init__.py:
--------------------------------------------------------------------------------
1 | __VERSION__ = '0.2.2'
2 | __AUTHOR__ = 'Danilo Bargen'
3 | __AUTHOR_EMAIL__ = 'mail@dbrgn.ch'
4 |
--------------------------------------------------------------------------------
/tangible/ast.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | AST module.
4 |
5 | This module contains the building blocks for an abstract syntax tree (AST)
6 | representation of 3D objects. It is implemented using namedtuples.
7 |
8 | """
9 | from __future__ import print_function, division, absolute_import, unicode_literals
10 |
11 | from itertools import chain
12 |
13 | __VERSION__ = '1'
14 |
15 |
16 | ### Base class for AST types ###
17 |
18 | class AST(object):
19 | """Base class for AST objects."""
20 |
21 | def __eq__(self, other):
22 | """This method override ensures that two objects are considered equal
23 | when their attributes match. Object identity is irrelevant."""
24 | return isinstance(other, self.__class__) \
25 | and self.__dict__ == other.__dict__
26 |
27 | def __ne__(self, other):
28 | """Inverse of ``__eq__``."""
29 | return not self.__eq__(other)
30 |
31 | def __repr__(self):
32 | name = self.__class__.__name__
33 | return ''.format(name, id(self))
34 |
35 |
36 | ### 2D shapes ###
37 |
38 | class Circle(AST):
39 | """A circle 2D shape."""
40 | def __init__(self, radius):
41 | """
42 | :param radius: The radius of the circle.
43 | :type radius: int or float
44 | :raises: ValueError if validation fails.
45 |
46 | """
47 | if radius <= 0:
48 | raise ValueError('Radius of a circle must be > 0.')
49 | self.radius = radius
50 |
51 |
52 | class CircleSector(Circle):
53 | """A circle sector (pizza slice)."""
54 | def __init__(self, radius, angle):
55 | """
56 | :param radius: The radius of the circle.
57 | :type radius: int or float
58 | :param angle: The central angle in degrees.
59 | :type angle: int or float
60 | :raises: ValueError if validation fails.
61 |
62 | """
63 | super(CircleSector, self).__init__(radius)
64 | if angle <= 0:
65 | raise ValueError('Angle must be > 0.')
66 | if angle > 360:
67 | raise ValueError('Angle must be between 0 and 360.')
68 | self.angle = angle
69 |
70 |
71 | class Rectangle(AST):
72 | """A rectangle 2D shape."""
73 | def __init__(self, width, height):
74 | """
75 | :param width: Width of the rectangle.
76 | :type width: int or float
77 | :param height: Height of the rectangle.
78 | :type height: int or float
79 | :raises: ValueError if validation fails.
80 |
81 | """
82 | if width <= 0:
83 | raise ValueError('Width must be > 0.')
84 | if height <= 0:
85 | raise ValueError('Height must be > 0.')
86 | self.width = width
87 | self.height = height
88 |
89 |
90 | class Polygon(AST):
91 | """A polygon 2D shape."""
92 | def __init__(self, points):
93 | """
94 | :param points: List of coordinates. Order of points is significant. The
95 | shape must be closed, meaning that the first and the last coordinate
96 | must be the same.
97 | :type points: list of 2-tuples
98 | :raises: ValueError if validation fails.
99 |
100 | """
101 | if points[0] != points[-1]:
102 | raise ValueError('The shape must be closed, meaning that the first '
103 | 'and the last coordinate must be the same.')
104 | if len(points) < 4:
105 | raise ValueError('A polygon consists of at least 3 points.')
106 | self.points = points
107 |
108 |
109 | # 3D shapes
110 |
111 | class Cube(AST):
112 | """A cube 3D shape."""
113 | def __init__(self, width, height, depth):
114 | """
115 | :param width: Width of the cube.
116 | :type width: int or float
117 | :param height: Height of the cube.
118 | :type height: int or float
119 | :param depth: Depth of the cube.
120 | :type depth: int or float
121 | :raises: ValueError if validation fails.
122 |
123 | """
124 | if width <= 0:
125 | raise ValueError('Width must be > 0.')
126 | if height <= 0:
127 | raise ValueError('Height must be > 0.')
128 | if depth <= 0:
129 | raise ValueError('Depth must be > 0.')
130 | self.width = width
131 | self.height = height
132 | self.depth = depth
133 |
134 |
135 | class Sphere(AST):
136 | """A sphere 3D shape."""
137 | def __init__(self, radius):
138 | """
139 | :param radius: The radius of the sphere.
140 | :type radius: int or float
141 | :raises: ValueError if validation fails.
142 |
143 | """
144 | if radius <= 0:
145 | raise ValueError('Radius of a sphere must be > 0.')
146 | self.radius = radius
147 |
148 |
149 | class Cylinder(AST):
150 | """A cylinder 3D shape."""
151 | def __init__(self, height, radius1, radius2):
152 | """
153 | :param height: The height of the cylinder.
154 | :type height: int or float
155 | :param radius1: The bottom radius of the cylinder.
156 | :type radius1: int or float
157 | :param radius2: The top radius of the cylinder.
158 | :type radius2: int or float
159 | :raises: ValueError if validation fails.
160 |
161 | """
162 | if height <= 0:
163 | raise ValueError('Height of a cylinder must be > 0.')
164 | if radius1 <= 0:
165 | raise ValueError('Bottom radius (radius1) of a cylinder must be > 0.')
166 | if radius2 <= 0:
167 | raise ValueError('Top radius (radius2) of a cylinder must be > 0.')
168 | self.height = height
169 | self.radius1 = radius1
170 | self.radius2 = radius2
171 |
172 |
173 | class Polyhedron(AST):
174 | """A polyhedron 3D shape. Supports both triangles and quads. Triangles and
175 | quads can also be mixed."""
176 | def __init__(self, points, triangles=[], quads=[]):
177 | """
178 | :param points: List of points.
179 | :type points: list of 3-tuples
180 | :param triangles: Triangles formed by a 3-tuple of point indexes (e.g.
181 | ``(0, 1, 3)``). When looking at the triangle from outside, the points
182 | must be in clockwise order. Default: ``[]``.
183 | :type triangles: list of 3-tuples
184 | :param quads: Rectangles formed by a 4-tuple of point indexes (e.g.
185 | ``(0, 1, 3, 4)``). When looking at the rectangle from outside, the
186 | points must be in clockwise order. Default: ``[]``.
187 | :type quads: list of 4-tuples
188 | :raises: ValueError if validation fails.
189 |
190 | """
191 | if len(points) < 4:
192 | raise ValueError('There must be at least 4 points in a polyhedron.')
193 | if set(map(len, points)) != {3}:
194 | raise ValueError('Invalid point tuples (must be 3-tuples).')
195 | if not (triangles or quads):
196 | raise ValueError('Either triangles or quads must be specified.')
197 | if triangles:
198 | if set(map(len, triangles)) != {3}:
199 | raise ValueError('Invalid triangle tuples (must be 3-tuples).')
200 | if quads:
201 | if set(map(len, quads)) != {4}:
202 | raise ValueError('Invalid quad tuples (must be 4-tuples).')
203 | max_value = max(chain(*(triangles + quads)))
204 | min_value = min(chain(*(triangles + quads)))
205 | if max_value >= len(points):
206 | raise ValueError('Invalid point index: {}'.format(max_value))
207 | if min_value < 0:
208 | raise ValueError('Invalid point index: {}'.format(min_value))
209 | self.points = points
210 | self.triangles = triangles
211 | self.quads = quads
212 |
213 |
214 | ### Transformations ###
215 |
216 | class Translate(AST):
217 | """A translate transformation."""
218 | def __init__(self, x, y, z, item):
219 | """
220 | :param x: Translation on the X axis.
221 | :type x: int or float
222 | :param y: Translation on the Y axis.
223 | :type y: int or float
224 | :param z: Translation on the Z axis.
225 | :type z: int or float
226 | :param item: An AST object.
227 | :type item: tangible.ast.AST
228 | :raises: ValueError if validation fails
229 |
230 | """
231 | if not item:
232 | raise ValueError('Item is required.')
233 | if not isinstance(item, AST):
234 | raise ValueError('Item must be an AST type.')
235 | self.x = x
236 | self.y = y
237 | self.z = z
238 | self.item = item
239 |
240 |
241 | class Rotate(AST):
242 | """A rotate transformation."""
243 | def __init__(self, degrees, vector, item):
244 | """
245 | :param degrees: Number of degrees to rotate.
246 | :type degrees: int or float
247 | :param vector: The axes to rotate around. When a rotation is specified
248 | for multiple axes then the rotation is applied in the following
249 | order: x, y, z. As an example, a vector of [1,1,0] will cause the object
250 | to be first rotated around the x axis, and then around the y axis.
251 | :type y: 3-tuple
252 | :param item: An AST object.
253 | :type item: tangible.ast.AST
254 | :raises: ValueError if validation fails
255 |
256 | """
257 | if not item:
258 | raise ValueError('Item is required.')
259 | if not isinstance(item, AST):
260 | raise ValueError('Item must be an AST type.')
261 | if not len(vector) == 3:
262 | raise ValueError('Invalid vector (must be a 3-tuple).')
263 | if not any(vector):
264 | raise ValueError('Invalid vector (must contain at least one `1` value).')
265 | if set(vector) != {0, 1}:
266 | raise ValueError('Invalid vector (must consist of `0` and `1` values).')
267 | self.degrees = degrees
268 | self.vector = vector
269 | self.item = item
270 |
271 |
272 | class Scale(AST):
273 | """A scale transformation."""
274 | def __init__(self, x, y, z, item):
275 | """
276 | The x, y and z attributes are multiplicators of the corresponding
277 | dimensions. E.g. to double the height of an object, you'd use ``1, 1, 2``
278 | as x, y and z values.
279 |
280 | :param x: X axis multiplicator.
281 | :type x: int or float
282 | :param y: Y axis multiplicator.
283 | :type y: int or float
284 | :param z: Z axis multiplicator.
285 | :type z: int or float
286 | :param item: An AST object.
287 | :type item: tangible.ast.AST
288 | :raises: ValueError if validation fails
289 |
290 | """
291 | if not item:
292 | raise ValueError('Item is required.')
293 | if not isinstance(item, AST):
294 | raise ValueError('Item must be an AST type.')
295 | if 0 in [x, y, z]:
296 | raise ValueError('Values of 0 are not allowed in a scale transformation.')
297 | self.x = x
298 | self.y = y
299 | self.z = z
300 | self.item = item
301 |
302 |
303 | class Mirror(AST):
304 | """A mirror transformation."""
305 | def __init__(self, vector, item):
306 | """
307 | Mirror the child element on a plane through the origin.
308 |
309 | :param vector: Normal vector describing the plane intersecting the
310 | origin through which to mirror the object.
311 | :type vector: 3-tuple
312 | :param item: An AST object.
313 | :type item: tangible.ast.AST
314 | :raises: ValueError if validation fails
315 |
316 | """
317 | if not item:
318 | raise ValueError('Item is required.')
319 | if not isinstance(item, AST):
320 | raise ValueError('Item must be an AST type.')
321 | if not len(vector) == 3:
322 | raise ValueError('Invalid vector (must be a 3-tuple).')
323 | if not any(vector):
324 | raise ValueError('Invalid vector (must contain at least one non-zero value).')
325 | self.vector = tuple(vector)
326 | self.item = item
327 |
328 |
329 | ### Boolean operations ###
330 |
331 | class _BooleanOperation(AST):
332 | """Base class for boolean operations that only take the ``items`` argument."""
333 | def __init__(self, items):
334 | """
335 | :param items: List of AST objects.
336 | :type items: list
337 | :raises: ValueError if validation fails
338 |
339 | """
340 | if not items:
341 | raise ValueError('Items are required.')
342 | if not hasattr(items, '__iter__'):
343 | raise ValueError('Items must be iterable.')
344 | if len(items) < 2:
345 | raise ValueError('Union must contain at least 2 items.')
346 | if not all(map(lambda x: isinstance(x, AST), items)):
347 | raise ValueError('All items must be AST types.')
348 | self.items = items
349 |
350 |
351 | class Union(_BooleanOperation):
352 | """A union operation."""
353 | pass
354 |
355 |
356 | class Difference(_BooleanOperation):
357 | """A difference operation."""
358 | pass
359 |
360 |
361 | class Intersection(_BooleanOperation):
362 | """A intersection operation."""
363 | pass
364 |
365 |
366 | ### Extrusions ###
367 |
368 | class LinearExtrusion(AST):
369 | """A linear extrusion along the z axis."""
370 | def __init__(self, height, item, twist=0):
371 | """
372 | :param height: The height of the extrusion.
373 | :type height: int or float
374 | :param item: An AST object.
375 | :type item: tangible.ast.AST
376 | :param twist: How many degrees to twist the object around the z axis.
377 | :type twist: int or float
378 |
379 | """
380 | if not item:
381 | raise ValueError('Item is required.')
382 | if not isinstance(item, AST):
383 | raise ValueError('Item must be an AST type.')
384 | self.height = height
385 | self.item = item
386 | self.twist = twist
387 |
388 |
389 | class RotateExtrusion(AST):
390 | """A rotational extrusion around the z axis."""
391 | def __init__(self, item):
392 | """
393 | :param item: An AST object.
394 | :type item: tangible.ast.AST
395 |
396 | """
397 | if not item:
398 | raise ValueError('Item is required.')
399 | if not isinstance(item, AST):
400 | raise ValueError('Item must be an AST type.')
401 | self.item = item
402 |
--------------------------------------------------------------------------------
/tangible/backends/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbrgn/tangible/59fd754421283b1bfe1306201bd3ea76f9dbbf87/tangible/backends/__init__.py
--------------------------------------------------------------------------------
/tangible/backends/openscad.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from contextlib import contextmanager
5 |
6 | from tangible import ast, utils
7 |
8 |
9 | class Statement(object):
10 |
11 | def __init__(self, text, *args, **kwargs):
12 | self.suffix = kwargs.pop('suffix', ';')
13 | if kwargs:
14 | raise TypeError('invalid keyword arguments %r' % list(kwargs.keys()))
15 | if args:
16 | text = text.format(*args)
17 | self.text = text
18 |
19 | def render(self):
20 | return [self.text + self.suffix]
21 |
22 |
23 | EmptyStatement = Statement('', suffix='')
24 |
25 |
26 | class Block(object):
27 |
28 | def __init__(self, text, *args, **kwargs):
29 | self.prefix = kwargs.pop('prefix', '{')
30 | self.suffix = kwargs.pop('suffix', '};')
31 | if kwargs:
32 | raise TypeError('invalid keyword arguments %r' % list(kwargs.keys()))
33 | self.title = Statement(text, *args, suffix='')
34 | self.children = []
35 | self.stack = []
36 |
37 | def _get_head(self):
38 | if not self.stack:
39 | return self
40 | else:
41 | return self.stack[-1]
42 |
43 | def emptyline(self, count=1):
44 | for i in range(count):
45 | self._get_head().children.append(EmptyStatement)
46 |
47 | def statement(self, *args, **kwargs):
48 | self._get_head().children.append(Statement(*args, **kwargs))
49 |
50 | @contextmanager
51 | def block(self, *args, **kwargs):
52 | blk = Block(*args, **kwargs)
53 | self._get_head().children.append(blk)
54 | self.stack.append(blk)
55 | yield blk
56 | self.stack.pop(-1)
57 |
58 | def render(self):
59 | lines = self.title.render()
60 | if self.prefix:
61 | lines.append(self.prefix)
62 | for child in self.children:
63 | lines.extend(' ' * 4 + l for l in child.render())
64 | if self.suffix:
65 | lines.append(self.suffix)
66 | return lines
67 |
68 |
69 | class Program(Block):
70 |
71 | def __init__(self):
72 | super(Program, self).__init__(None)
73 | self._preamble = set()
74 |
75 | def __enter__(self):
76 | return self
77 |
78 | def __exit__(self, type, value, traceback):
79 | pass
80 |
81 | def preamble(self, item):
82 | self._preamble.add(item)
83 |
84 | def render(self):
85 | lines = list(self._preamble)
86 | if self._preamble:
87 | lines.append('')
88 | for child in self.children:
89 | lines.extend(child.render())
90 | return '\n'.join(lines)
91 |
92 |
93 | class OpenScadBackend(object):
94 | """Render AST to OpenSCAD source code."""
95 |
96 | def __init__(self, ast):
97 | """
98 | :param ast: The AST that should be rendered.
99 | :type ast: Any :class:`tangible.ast.AST` subclass
100 |
101 | """
102 | self.ast = ast
103 |
104 | def generate(self):
105 | """Generate OpenSCAD source code from the AST."""
106 | prgm = Program()
107 | BLOCK = prgm.block
108 | STMT = prgm.statement
109 | PRE = prgm.preamble
110 | SEP = prgm.emptyline
111 |
112 | def _generate(node):
113 | """Recursive code generating function."""
114 |
115 | istype = lambda t: node.__class__ is t
116 |
117 | # Handle lists
118 |
119 | if istype(list):
120 | for item in node:
121 | _generate(item)
122 |
123 | # 2D shapes
124 |
125 | elif istype(ast.Circle):
126 | STMT('circle({})', node.radius)
127 | elif istype(ast.Rectangle):
128 | STMT('square([{}, {}])', node.width, node.height)
129 | elif istype(ast.Polygon):
130 | points = [list(p) for p in node.points]
131 | STMT('polygon({0!r})', points[:-1])
132 | elif istype(ast.CircleSector):
133 | PRE('module circle_sector(r, a) {\n'
134 | ' a1 = a % 360;\n'
135 | ' a2 = 360 - (a % 360);\n'
136 | ' if (a1 <= 180) {\n'
137 | ' intersection() {\n'
138 | ' circle(r);\n'
139 | ' polygon([\n'
140 | ' [0,0],\n'
141 | ' [0,r],\n'
142 | ' [sin(a1/2)*r, r + cos(a1/2)*r],\n'
143 | ' [sin(a1)*r + sin(a1/2)*r, cos(a1)*r + cos(a1/2)*r],\n'
144 | ' [sin(a1)*r, cos(a1)*r],\n'
145 | ' ]);\n'
146 | ' }\n'
147 | ' } else {\n'
148 | ' difference() {\n'
149 | ' circle(r);\n'
150 | ' mirror([1,0]) {\n'
151 | ' polygon([\n'
152 | ' [0,0],\n'
153 | ' [0,r],\n'
154 | ' [sin(a2/2)*r, r + cos(a2/2)*r],\n'
155 | ' [sin(a2)*r + sin(a2/2)*r, cos(a2)*r + cos(a2/2)*r],\n'
156 | ' [sin(a2)*r, cos(a2)*r],\n'
157 | ' ]);\n'
158 | ' };\n'
159 | ' }\n'
160 | ' }\n'
161 | '};')
162 | STMT('circle_sector({}, {})', node.radius, node.angle)
163 |
164 | # 3D shapes
165 |
166 | elif istype(ast.Cube):
167 | STMT('cube([{}, {}, {}])', node.width, node.depth, node.height)
168 | elif istype(ast.Sphere):
169 | STMT('sphere({})', node.radius)
170 | elif istype(ast.Cylinder):
171 | STMT('cylinder({}, {}, {})', node.height, node.radius1, node.radius2)
172 | elif istype(ast.Polyhedron):
173 | points = [list(p) for p in node.points]
174 | triangles = [list(t) for t in node.triangles] if node.triangles else []
175 | if node.quads:
176 | triangles.extend(utils._quads_to_triangles(node.quads))
177 | template = 'polyhedron(\npoints={0!r},\n triangles={1!r}\n)'
178 | STMT(template, points, triangles)
179 |
180 | # Transformations
181 |
182 | elif istype(ast.Translate):
183 | with BLOCK('translate([{}, {}, {}])', node.x, node.y, node.z):
184 | _generate(node.item)
185 | elif istype(ast.Rotate):
186 | with BLOCK('rotate({0}, {1!r})', node.degrees, list(node.vector)):
187 | _generate(node.item)
188 | elif istype(ast.Scale):
189 | with BLOCK('scale([{}, {}, {}])', node.x, node.y, node.z):
190 | _generate(node.item)
191 | elif istype(ast.Mirror):
192 | with BLOCK('mirror({0!r})', list(node.vector)):
193 | _generate(node.item)
194 |
195 | # Boolean operations
196 |
197 | elif istype(ast.Union):
198 | with BLOCK('union()'):
199 | _generate(node.items)
200 | elif istype(ast.Difference):
201 | with BLOCK('difference()'):
202 | _generate(node.items)
203 | elif istype(ast.Intersection):
204 | with BLOCK('intersection()'):
205 | _generate(node.items)
206 |
207 | # Extrusions
208 |
209 | elif istype(ast.LinearExtrusion):
210 | with BLOCK('linear_extrude({}, twist={})', node.height, node.twist):
211 | _generate(node.item)
212 | elif istype(ast.RotateExtrusion):
213 | with BLOCK('rotate_extrude()'):
214 | _generate(node.item)
215 |
216 | _generate(self.ast)
217 |
218 | return prgm.render()
219 |
--------------------------------------------------------------------------------
/tangible/scales.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 |
5 | def _clamp(value, domain):
6 | """
7 | Clamp function. Limits the value to the given domain.
8 |
9 | (TODO: Maybe rewriting this using ``numpy.clip`` would make it faster?)
10 |
11 | :param value: The value to clamp.
12 | :param domain: A 2-tuple with the domain to clamp to.
13 | :returns: The value, clamped to the specified domain.
14 |
15 | """
16 | return sorted((domain[0], value, domain[1]))[1]
17 |
18 |
19 | def linear(domain, codomain, clamp=False):
20 | """
21 | Return a function to linearly scale the values in the ``domain`` range to
22 | values in the ``codomain`` range.
23 |
24 | :param domain: The scale's input domain.
25 | :type domain: A 2-tuple.
26 | :param codomain: The scale's output range / codomain.
27 | :type codomain: A 2-tuple.
28 | :param clamp: Whether or not to clamp the output values to the codomain.
29 | Default ``False``.
30 | :type clamp: bool
31 | :returns: A function that takes a single number as argument and returns
32 | another number.
33 |
34 | """
35 | d, c = domain, codomain # Short aliases
36 | d_size = d[1] - d[0]
37 | c_size = c[1] - c[0]
38 |
39 | def scale(x):
40 | value = (c_size / d_size) * (x - d[0]) + c[0]
41 | return _clamp(value, codomain) if clamp is True else value
42 |
43 | return scale
44 |
--------------------------------------------------------------------------------
/tangible/shapes/__init__.py:
--------------------------------------------------------------------------------
1 | from . import base, vertical, bars, pie
2 |
--------------------------------------------------------------------------------
/tangible/shapes/bars.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """Bar shapes."""
3 | from __future__ import print_function, division, absolute_import, unicode_literals
4 |
5 | from .. import ast
6 | from .base import Shape
7 | from .mixins import Data1DMixin, DataNDMixin
8 |
9 |
10 | ### BASE CLASS ###
11 |
12 | class BarsShape(Shape):
13 | """Base class for vertical bars.
14 |
15 | :param data: The data.
16 | :type data: sequence type
17 | :param bar_width: The width of each bar.
18 | :type bar_width: int or float
19 | :param bar_depth: The depth of each bar.
20 | :type bar_depth: int or float
21 |
22 | """
23 | def __init__(self, data, bar_width, bar_depth):
24 | super(BarsShape, self).__init__(data)
25 | self.bar_width = bar_width
26 | self.bar_depth = bar_depth
27 |
28 |
29 | ### SHAPE CLASSES ###
30 |
31 | class Bars1D(Data1DMixin, BarsShape):
32 | """Vertical bars aligned next to each other horizontally. Datapoints are
33 | mapped to bar height."""
34 | def _build_ast(self):
35 | bars = []
36 | for i, datapoint in enumerate(self.data[0]):
37 | bar = ast.Cube(width=self.bar_width, height=datapoint, depth=self.bar_depth)
38 | translated_bar = ast.Translate(x=i * self.bar_width, y=0, z=0, item=bar)
39 | bars.append(translated_bar)
40 | model = ast.Union(items=bars)
41 | # Center model
42 | x_offset = len(self.data) / 2 * self.bar_width
43 | return ast.Translate(x=-x_offset, y=0, z=0, item=model)
44 |
45 |
46 | class BarsND(DataNDMixin, BarsShape):
47 | """Vertical bars aligned next to each other horizontally. Datapoints are
48 | mapped to bar height. Multiple layers of bars (matching number of
49 | datasets)."""
50 | def __init__(self, data, bar_width, bar_depth, center_layers=False):
51 | """
52 | :param center_layers: Whether or not to center the layers
53 | horizontally (default False).
54 | :type center_layers: bool
55 |
56 | """
57 | super(BarsND, self).__init__(data, bar_width, bar_depth)
58 | self.center_layers = center_layers
59 |
60 | def _build_ast(self):
61 | layers = []
62 | for i, month in enumerate(self.data):
63 | bars1d = Bars1D(month, self.bar_width, self.bar_depth)
64 | layer = bars1d._build_ast()
65 | if not self.center_layers:
66 | layer = layer.item
67 |
68 | # Hack: Used to prevent "invalid 2-manifold" error
69 | # TODO: should probably be in backend
70 | x_offset = (i % 2) * 0.1
71 |
72 | translated = ast.Translate(x=x_offset, y=i * self.bar_depth, z=0, item=layer)
73 | layers.append(translated)
74 | model = ast.Union(items=layers)
75 |
76 | # Center model
77 | # x_offset = TODO
78 | y_offset = len(self.data) / 2 * self.bar_depth
79 |
80 | return ast.Translate(x=0, y=-y_offset, z=0, item=model)
81 |
--------------------------------------------------------------------------------
/tangible/shapes/base.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from .. import utils
5 |
6 |
7 | class BaseShape(object):
8 | """The base shape.
9 |
10 | In contrast to the :class:`Shape` class, it works without data. It provides
11 | a ``render`` method and an unimplemented ``_build_ast`` stub.
12 |
13 | """
14 | def _build_ast(self):
15 | raise NotImplementedError('_build_ast method not implemented.')
16 |
17 | def render(self, backend):
18 | """Build the AST_ and generate code using the selected backend_.
19 |
20 | :param backend: The backend_ class used to process the AST_. Must accept
21 | the AST as constructor argument and provide a ``generate()`` method.
22 | :returns: The resulting source code as a string.
23 |
24 | """
25 | ast = self._build_ast()
26 | return backend(ast).generate()
27 |
28 |
29 | class Shape(BaseShape):
30 | """The base class for all shapes.
31 |
32 | This class provides the base functionality to store data, build an `AST
33 | `_ and render it using the selected `backend `_.
34 |
35 | """
36 | def __init__(self, data):
37 | """
38 | :param data: The data.
39 | :type data: sequence type
40 | :raises: ValueError if data is empty.
41 | """
42 | self.data = utils._ensure_list_of_lists(data)
43 | if len(self.data[0]) == 0:
44 | raise ValueError('Data may not be empty.')
45 |
--------------------------------------------------------------------------------
/tangible/shapes/mixins.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """Shape mixins, mostly to validate data."""
3 | from __future__ import print_function, division, absolute_import, unicode_literals
4 |
5 | from .. import utils
6 |
7 |
8 | # TODO validators could be implemented as parametrized decorators instead of
9 | # using classes directly.
10 |
11 |
12 | class Data1DMixin(object):
13 | """Validate 1 dimensional data."""
14 | def __init__(self, data, *args, **kwargs):
15 | data = utils._ensure_list_of_lists(data)
16 | if len(data) != 1:
17 | msg = 'Data must be 1-dimensional, but it contains {} datasets.'
18 | raise ValueError(msg.format(len(data)))
19 | super(Data1DMixin, self).__init__(data, *args, **kwargs)
20 |
21 |
22 | class Data2DMixin(object):
23 | """Validate 2 dimensional data."""
24 | def __init__(self, data, *args, **kwargs):
25 | data = utils._ensure_list_of_lists(data)
26 | if len(data) != 2:
27 | msg = 'Data must be 2-dimensional, but it contains {} datasets.'
28 | raise ValueError(msg.format(len(data)))
29 | super(Data2DMixin, self).__init__(data, *args, **kwargs)
30 |
31 |
32 | class Data3DMixin(object):
33 | """Validate 3 dimensional data."""
34 | def __init__(self, data, *args, **kwargs):
35 | data = utils._ensure_list_of_lists(data)
36 | if len(data) != 3:
37 | msg = 'Data must be 3-dimensional, but it contains {} datasets.'
38 | raise ValueError(msg.format(len(data)))
39 | super(Data3DMixin, self).__init__(data, *args, **kwargs)
40 |
41 |
42 | class Data4DMixin(object):
43 | """Validate 4 dimensional data."""
44 | def __init__(self, data, *args, **kwargs):
45 | data = utils._ensure_list_of_lists(data)
46 | if len(data) != 4:
47 | msg = 'Data must be 4-dimensional, but it contains {} datasets.'
48 | raise ValueError(msg.format(len(data)))
49 | super(Data4DMixin, self).__init__(data, *args, **kwargs)
50 |
51 |
52 | class DataNDMixin(object):
53 | """Validate n dimensional data."""
54 | def __init__(self, data, *args, **kwargs):
55 | data = utils._ensure_list_of_lists(data)
56 | if not len(data):
57 | raise ValueError('Data must not be empty.')
58 | if not all(map(lambda x: hasattr(x, '__iter__'), data)):
59 | raise ValueError('All data items must be a sequence type (e.g. a list).')
60 | super(DataNDMixin, self).__init__(data, *args, **kwargs)
61 |
62 |
63 | class SameLengthDatasetMixin(object):
64 | """Make sure that each dataset in multi dimensional data has the same
65 | length."""
66 | def __init__(self, data, *args, **kwargs):
67 | data = utils._ensure_list_of_lists(data)
68 | lengths = map(len, data)
69 | if len(set(lengths)) != 1:
70 | raise ValueError('All datasets in data must be of the same length.')
71 | super(SameLengthDatasetMixin, self).__init__(data, *args, **kwargs)
72 |
--------------------------------------------------------------------------------
/tangible/shapes/pie.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """Circular shapes."""
3 | from __future__ import print_function, division, absolute_import, unicode_literals
4 |
5 | from math import sin, cos, radians
6 |
7 | try:
8 | from itertools import izip as zip
9 | except: # This import fails in python3 because zip is now a builtin
10 | pass
11 |
12 | from .. import ast, scales
13 | from .base import Shape
14 | from .mixins import SameLengthDatasetMixin, Data1DMixin, Data2DMixin, Data3DMixin
15 |
16 |
17 | ### BASE CLASS ###
18 |
19 | class PieShape(SameLengthDatasetMixin, Shape):
20 | """Base class for pie shapes.
21 |
22 | :param data: The data.
23 | :type data: sequence type
24 | :param height: The height of the model (default 2).
25 | :type height: int or float
26 | :param outer_radius: The outer radius of the model (default 10).
27 | :type outer_radius: int or float
28 | :param inner_radius: The inner radius of the model (default 0).
29 | :type inner_radius: int or float
30 | :param explode: By how much to explode the sectors (default 0).
31 | :type explode: int or float
32 |
33 | """
34 | def __init__(self, data, height=2, outer_radius=10, inner_radius=0, explode=0):
35 | super(PieShape, self).__init__(data)
36 | self.inner_radius = inner_radius
37 | self.count = len(self.data[0])
38 | self.radii = [outer_radius] * self.count
39 | self.angles = [360 / self.count] * self.count
40 | self.heights = [height] * self.count
41 | self.explode = explode
42 |
43 | def _build_ast(self):
44 | slices = []
45 | total_angle = 0
46 | for i, (radius, angle, height) in enumerate(zip(self.radii, self.angles, self.heights)):
47 | # Create slice
48 | s = ast.CircleSector(radius, angle)
49 | # Explode
50 | if self.explode:
51 | x_offset = self.explode * sin(radians(angle / 2))
52 | y_offset = self.explode * cos(radians(angle / 2))
53 | s = ast.Translate(x_offset, y_offset, 0, s)
54 | # Rotate
55 | s = ast.Rotate(-total_angle, (0, 0, 1), s)
56 | total_angle += angle
57 | # Extrude
58 | s = ast.LinearExtrusion(height, s)
59 | slices.append(s)
60 | union = ast.Union(slices)
61 | if self.inner_radius:
62 | r = self.inner_radius
63 | center = ast.Cylinder(max(self.heights), r, r)
64 | return ast.Difference([union, center])
65 | return union
66 |
67 |
68 | ### MIXINS ###
69 |
70 | class AngleMixin(object):
71 | """Use datapoint as angle."""
72 | def __init__(self, *args, **kwargs):
73 | index = kwargs.pop('angle_index', 0)
74 | super(AngleMixin, self).__init__(*args, **kwargs)
75 | data = self.data[index]
76 | scale = scales.linear([0, sum(data)], [0, 360])
77 | for i in range(self.count):
78 | self.angles[i] = scale(data[i])
79 |
80 |
81 | class RadiusMixin(object):
82 | """Use datapoint as outer radius."""
83 | def __init__(self, *args, **kwargs):
84 | index = kwargs.pop('radius_index', 0)
85 | super(RadiusMixin, self).__init__(*args, **kwargs)
86 | data = self.data[index]
87 | self.radii = data
88 |
89 |
90 | class HeightMixin(object):
91 | """Use datapoint as height."""
92 | def __init__(self, *args, **kwargs):
93 | index = kwargs.pop('height_index', 0)
94 | super(HeightMixin, self).__init__(*args, **kwargs)
95 | data = self.data[index]
96 | self.heights = data
97 |
98 |
99 | ### SHAPE CLASSES ###
100 |
101 | class AnglePie1D(Data1DMixin, AngleMixin, PieShape):
102 | """A classical pie chart. The datapoints are mapped to the angles of the slices.
103 |
104 | Note that you won't be able to differentiate the slices without setting a
105 | positive ``explode`` value.
106 |
107 | """
108 | def __init__(self, data, height=2, outer_radius=10, inner_radius=0, explode=0):
109 | """
110 | :param data: The data.
111 | :type data: sequence type
112 | :param height: The height of the model (default 2).
113 | :type height: int or float
114 | :param outer_radius: The outer radius of the model (default 10).
115 | :type outer_radius: int or float
116 | :param inner_radius: The inner radius of the model (default 0).
117 | :type inner_radius: int or float
118 | :param explode: By how much to explode the sectors (default 0).
119 | :type explode: int or float
120 |
121 | """
122 | super(AnglePie1D, self).__init__(data, height=height,
123 | outer_radius=outer_radius, inner_radius=inner_radius,
124 | explode=explode)
125 |
126 |
127 | class RadiusPie1D(Data1DMixin, RadiusMixin, PieShape):
128 | """A flat pie chart where the datapoints are mapped to the radius of the
129 | slices."""
130 | def __init__(self, data, height=2, inner_radius=0, explode=0):
131 | """
132 | :param data: The data.
133 | :type data: sequence type
134 | :param height: The height of the model (default 2).
135 | :type height: int or float
136 | :param inner_radius: The inner radius of the model (default 0).
137 | :type inner_radius: int or float
138 | :param explode: By how much to explode the sectors (default 0).
139 | :type explode: int or float
140 |
141 | """
142 | super(RadiusPie1D, self).__init__(data, height=height,
143 | inner_radius=inner_radius, explode=explode)
144 |
145 |
146 | class HeightPie1D(Data1DMixin, HeightMixin, PieShape):
147 | """A pie chart where the datapoints are mapped to the height of the
148 | slices."""
149 | def __init__(self, data, outer_radius=10, inner_radius=0, explode=0):
150 | """
151 | :param data: The data.
152 | :type data: sequence type
153 | :param outer_radius: The outer radius of the model (default 10).
154 | :type outer_radius: int or float
155 | :param inner_radius: The inner radius of the model (default 0).
156 | :type inner_radius: int or float
157 | :param explode: By how much to explode the sectors (default 0).
158 | :type explode: int or float
159 |
160 | """
161 | super(HeightPie1D, self).__init__(data,
162 | outer_radius=outer_radius, inner_radius=inner_radius, explode=explode)
163 |
164 |
165 | class AngleRadiusPie2D(Data2DMixin, AngleMixin, RadiusMixin, PieShape):
166 | """A flat pie chart where the two datasets correspond to the angle and the
167 | radius of the slices."""
168 | def __init__(self, data, height, angle_index=0, radius_index=1, inner_radius=0, explode=0):
169 | """
170 | :param data: The data.
171 | :type data: sequence type
172 | :param height: The height of the model (default 2).
173 | :type height: int or float
174 | :param angle_index: The index of the angle dataset (default 0).
175 | :type angle_index: int
176 | :param radius_index: The index of the radius dataset (default 1).
177 | :type radius_index: int
178 | :param inner_radius: The inner radius of the model (default 0).
179 | :type inner_radius: int or float
180 | :param explode: By how much to explode the sectors (default 0).
181 | :type explode: int or float
182 |
183 | """
184 | super(AngleRadiusPie2D, self).__init__(data, height=height,
185 | inner_radius=inner_radius, explode=explode,
186 | angle_index=angle_index, radius_index=radius_index)
187 |
188 |
189 | class AngleHeightPie2D(Data2DMixin, AngleMixin, HeightMixin, PieShape):
190 | """A pie chart where the two datasets correspond to the angle and the
191 | height of the slices."""
192 | def __init__(self, data, angle_index=0, height_index=1, outer_radius=10, inner_radius=0,
193 | explode=0):
194 | """
195 | :param data: The data.
196 | :type data: sequence type
197 | :param angle_index: The index of the angle dataset (default 0).
198 | :type angle_index: int
199 | :param height_index: The index of the height dataset (default 1).
200 | :type height_index: int
201 | :param outer_radius: The outer radius of the model (default 10).
202 | :type outer_radius: int or float
203 | :param inner_radius: The inner radius of the model (default 0).
204 | :type inner_radius: int or float
205 | :param explode: By how much to explode the sectors (default 0).
206 | :type explode: int or float
207 |
208 | """
209 | super(AngleHeightPie2D, self).__init__(data,
210 | outer_radius=outer_radius, inner_radius=inner_radius, explode=explode,
211 | angle_index=angle_index, height_index=height_index)
212 |
213 |
214 | class RadiusHeightPie2D(Data2DMixin, RadiusMixin, HeightMixin, PieShape):
215 | """A pie chart where the two datasets correspond to the radius and the
216 | height of the slices."""
217 | def __init__(self, data, radius_index=0, height_index=1, inner_radius=0, explode=0):
218 | """
219 | :param data: The data.
220 | :type data: sequence type
221 | :param radius_index: The index of the radius dataset (default 0).
222 | :type radius_index: int
223 | :param height_index: The index of the height dataset (default 1).
224 | :type height_index: int
225 | :param inner_radius: The inner radius of the model (default 0).
226 | :type inner_radius: int or float
227 | :param explode: By how much to explode the sectors (default 0).
228 | :type explode: int or float
229 |
230 | """
231 | super(RadiusHeightPie2D, self).__init__(data,
232 | inner_radius=inner_radius, explode=explode,
233 | radius_index=radius_index, height_index=height_index)
234 |
235 |
236 | class AngleRadiusHeightPie3D(Data3DMixin, AngleMixin, RadiusMixin, HeightMixin, PieShape):
237 | """A pie chart where the three datasets correspond to the angle, the radius
238 | and the height of the slices."""
239 | def __init__(self, data, angle_index=0, radius_index=1, height_index=2, inner_radius=0,
240 | explode=0):
241 | """
242 | :param data: The data.
243 | :type data: sequence type
244 | :param angle_index: The index of the angle dataset (default 0).
245 | :type angle_index: int
246 | :param radius_index: The index of the radius dataset (default 1).
247 | :type radius_index: int
248 | :param height_index: The index of the height dataset (default 2).
249 | :type height_index: int
250 | :param inner_radius: The inner radius of the model (default 0).
251 | :type inner_radius: int or float
252 | :param explode: By how much to explode the sectors (default 0).
253 | :type explode: int or float
254 |
255 | """
256 | super(AngleRadiusHeightPie3D, self).__init__(data,
257 | inner_radius=inner_radius, explode=explode,
258 | angle_index=angle_index, radius_index=radius_index, height_index=height_index)
259 |
--------------------------------------------------------------------------------
/tangible/shapes/vertical.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """Vertical shapes."""
3 | from __future__ import print_function, division, absolute_import, unicode_literals
4 |
5 | try:
6 | from itertools import izip as zip
7 | except: # This import fails in python3 because zip is now a builtin
8 | pass
9 |
10 | from .. import ast, utils
11 | from .base import Shape
12 | from .mixins import Data1DMixin, Data2DMixin, Data4DMixin, SameLengthDatasetMixin
13 |
14 |
15 | ### BASE CLASS ###
16 |
17 | class VerticalShape(Shape):
18 | """Base class for vertical shapes like towers.
19 |
20 | :param data: The data.
21 | :type data: sequence type
22 | :param layer_height: The height of each layer in the vertical shape.
23 | :type layer_height: int or float
24 |
25 | """
26 | def __init__(self, data, layer_height):
27 | super(VerticalShape, self).__init__(data)
28 | self.layer_height = layer_height
29 |
30 |
31 | ### SHAPE CLASSES ###
32 |
33 | class CircleTower1D(Data1DMixin, VerticalShape):
34 | """Round vertical tower. Datapoints are mapped to radius."""
35 | def _build_ast(self):
36 | layers = [ast.Circle(radius=d) for d in self.data[0]]
37 | return utils.connect_2d_shapes(layers, self.layer_height, 'vertical')
38 |
39 |
40 | class SquareTower1D(Data1DMixin, VerticalShape):
41 | """Vertical tower made of squares. Datapoints are mapped to square side length."""
42 | def _build_ast(self):
43 | layers = [ast.Rectangle(width=d, height=d) for d in self.data[0]]
44 | return utils.connect_2d_shapes(layers, self.layer_height, 'vertical')
45 |
46 |
47 | class RectangleTower2D(Data2DMixin, SameLengthDatasetMixin, VerticalShape):
48 | """Vertical tower made of rectangles. Datapoints are mapped to width and
49 | height of rectangle."""
50 | def _build_ast(self):
51 | layers = [ast.Rectangle(width=a, height=b) for a, b in zip(*self.data)]
52 | return utils.connect_2d_shapes(layers, self.layer_height, 'vertical')
53 |
54 |
55 | class RhombusTower2D(Data2DMixin, SameLengthDatasetMixin, VerticalShape):
56 | """Vertical tower made of rhombi. Datapoints are mapped to distance between
57 | opposing corners."""
58 | def _build_ast(self):
59 | layers = []
60 | for a, b in zip(*self.data):
61 | rhombus = ast.Polygon([(0, a / 2), (b / 2, 0), (0, -a / 2), (-b / 2, 0), (0, a / 2)])
62 | layers.append(rhombus)
63 | return utils.connect_2d_shapes(layers, self.layer_height, 'vertical')
64 |
65 |
66 | class QuadrilateralTower4D(Data4DMixin, SameLengthDatasetMixin, VerticalShape):
67 | """Vertical tower made of quadrilaterals (polygons with 4 vertices).
68 | Datapoints are mapped to distance between center and the corners."""
69 | def _build_ast(self):
70 | layers = []
71 | for a, b, c, d in zip(*self.data):
72 | quadrilateral = ast.Polygon([(0, a), (b, 0), (0, -c), (-d, 0), (0, a)])
73 | layers.append(quadrilateral)
74 | return utils.connect_2d_shapes(layers, self.layer_height, 'vertical')
75 |
76 | # TODO: PolygonTowerND
77 |
--------------------------------------------------------------------------------
/tangible/utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | from itertools import tee
5 |
6 | try:
7 | from itertools import izip as zip
8 | except: # This import fails in python3 because zip is now a builtin
9 | pass
10 |
11 | from .ast import Circle, Rectangle, Polygon, Cylinder, Polyhedron, Union, Rotate, Translate
12 |
13 |
14 | def pairwise(iterable):
15 | """Iterate over an iterable in pairs.
16 |
17 | This is an implementation of a moving window over an iterable with 2 items.
18 | Each group in the resulting list contains 2 items. This means that the
19 | original iterable needs to contain at least 2 items, otherwise this
20 | function will return an empty list.
21 |
22 | Example::
23 |
24 | [1, 2, 3, 4] -> [(1, 2), (2, 3), (3, 4)]
25 |
26 | :param iterable: An iterable containing at least 2 items.
27 | :type iterable: Any iterable type (e.g. a list or a tuple).
28 | :returns: A generator returning pairwise items.
29 | :rtype: :class:`itertools.izip`
30 |
31 | """
32 | a, b = tee(iterable)
33 | next(b, None)
34 | return zip(a, b)
35 |
36 |
37 | def reduceby(iterable, keyfunc, reducefunc, init):
38 | """Combination of ``itertools.groupby()`` and ``reduce()``.
39 |
40 | This generator iterates over the iterable. The values are reduced using
41 | ``reducefunc`` and ``init`` as long as ``keyfunc(item)`` returns the same
42 | value.
43 |
44 | A possible use case would be to aggregate website visits and to group them
45 | by month. The corresponding SQL statement would be::
46 |
47 | SELECT SUM(visit_count) FROM visits GROUP BY month;
48 |
49 | Example::
50 |
51 | >>> keyfunc = lambda x: x % 2 == 0
52 | >>> reducefunc = lambda x, y: x + y
53 | >>> values = [1, 3, 5, 6, 8, 11]
54 | >>> groups = utils.reduceby(values, keyfunc, reducefunc, 0)
55 | >>> groups
56 |
57 | >>> list(groups)
58 | [9, 14, 11]
59 |
60 | :param iterable: An iterable to reduce. The iterable should be presorted.
61 | :param keyfunc: A key function. It should return the same value for all
62 | items belonging to the same group.
63 | :param reducefunc: The reduce function.
64 | :param init: The initial value for the reduction.
65 | :returns: A generator returning the reduced groups.
66 | :rtype: generator
67 |
68 | """
69 | first = True
70 | oldkey = None
71 | accum_value = init
72 | for i in iter(iterable):
73 | key = keyfunc(i)
74 | if first:
75 | oldkey = key
76 | first = False
77 | elif key != oldkey:
78 | yield accum_value
79 | accum_value = init
80 | oldkey = key
81 | accum_value = reducefunc(accum_value, i)
82 | yield accum_value
83 |
84 |
85 | def connect_2d_shapes(shapes, layer_distance, orientation):
86 | """Convert a list of 2D shapes to a 3D shape.
87 |
88 | Take a list of 2D shapes and create a 3D shape from it. Each layer is
89 | separated by the specified layer distance.
90 |
91 | :param shapes: List of shapes.
92 | :type shapes: Each shape in the list should be an AST object.
93 | :param layer_distance: The distance between two layers.
94 | :type layer_distance: int or float
95 | :param orientation: Either 'horizontal' or 'vertical'
96 | :type orientation: str or unicode
97 | :returns: :class:`ast.Union`
98 |
99 | """
100 | assert orientation in ['horizontal', 'vertical'], \
101 | '`orientation` argument must be either "horizontal" or "vertical".'
102 |
103 | layers = []
104 |
105 | for i, (first, second) in enumerate(pairwise(shapes)):
106 |
107 | layer = None
108 |
109 | # Validate type
110 | if type(first) != type(second):
111 | raise NotImplementedError('Joining different shape types is not currently supported.')
112 |
113 | # Circle
114 | # Implemented by joining cylinders.
115 | if isinstance(first, Circle):
116 | r1, r2 = first.radius, second.radius
117 | layer = Cylinder(height=layer_distance, radius1=r1, radius2=r2)
118 |
119 | # Rectangle
120 | # Implemented by joining polyhedra.
121 | elif isinstance(first, Rectangle):
122 | w1, h1 = first.width, first.height
123 | w2, h2 = second.width, second.height
124 |
125 | def get_layer_points(x, y, z):
126 | return [
127 | [x / 2, y / 2, z],
128 | [-x / 2, y / 2, z],
129 | [-x / 2, -y / 2, z],
130 | [x / 2, -y / 2, z],
131 | ]
132 |
133 | points = []
134 | points.extend(get_layer_points(w1, h1, 0))
135 | points.extend(get_layer_points(w2, h2, layer_distance))
136 |
137 | quads = [
138 | # Bottom
139 | [0, 1, 2, 3],
140 | # Top
141 | [4, 7, 6, 5],
142 | # Sides
143 | [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0],
144 | ]
145 |
146 | layer = Polyhedron(points=points, quads=quads)
147 |
148 | # Polygon
149 | # Implemented by joining polyhedra.
150 | elif isinstance(first, Polygon):
151 | if len(first.points) != len(second.points):
152 | raise ValueError('All polygons need to have the same number of points.')
153 |
154 | vertice_count = len(first.points) - 1
155 |
156 | points = []
157 | for point in first.points[:-1]:
158 | points.append(list(point) + [0])
159 | for point in second.points[:-1]:
160 | points.append(list(point) + [layer_distance])
161 |
162 | triangles = []
163 | quads = []
164 | for j in range(vertice_count):
165 | # Sides
166 | quads.append([
167 | (j + 1) % vertice_count, # lower right
168 | j, # lower left
169 | vertice_count + j, # upper left
170 | vertice_count + (j + 1) % vertice_count # upper right
171 | ])
172 | if j >= 2 and j < vertice_count:
173 | # Bottom
174 | triangles.append([0, j - 1, j])
175 | # Top
176 | triangles.append([vertice_count + j, vertice_count + j - 1, vertice_count])
177 |
178 | layer = Polyhedron(points=points, quads=quads, triangles=triangles)
179 |
180 | else:
181 | raise ValueError('Unsupported shape: {!r}'.format(first))
182 |
183 | layers.append(Translate(0, 0, i * layer_distance, item=layer))
184 | union = Union(items=layers)
185 | if orientation == 'horizontal':
186 | return Rotate(degrees=90, vector=[0, 1, 0], item=union)
187 | return union
188 |
189 |
190 | def _quads_to_triangles(quads):
191 | """Convert a list of quads to a list of triangles.
192 |
193 | :param quads: The list of quads.
194 | :type quads: list of 4-tuples
195 | :returns: List of triangles.
196 | :rtype: list of 3-tuples
197 |
198 | """
199 | triangles = []
200 | for quad in quads:
201 | triangles.append((quad[0], quad[1], quad[2]))
202 | triangles.append((quad[0], quad[2], quad[3]))
203 | return triangles
204 |
205 |
206 | def _ensure_list_of_lists(data):
207 | """Ensure the data object is a list of lists.
208 |
209 | If it doesn't contain lists or tuples, wrap it in a list.
210 |
211 | :param data: The dataset.
212 | :type data: list or tuple
213 | :returns: Processed data.
214 | :rtype: list of lists
215 | :raises: ValueError if data is not a sequence type.
216 |
217 | """
218 | if not hasattr(data, '__iter__'):
219 | raise ValueError('Data must be a sequence type (e.g. a list)')
220 | if not data:
221 | return [[]]
222 | if hasattr(data[0], '__iter__'):
223 | return data
224 | return [data]
225 |
--------------------------------------------------------------------------------
/tests/test_ast.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible import ast
7 |
8 |
9 | ### Equality ###
10 |
11 | def test_equality():
12 | """Equality should only compare attribute values, not identity."""
13 | c1 = ast.Circle(42)
14 | c2 = ast.Circle(42)
15 | assert c1 == c2
16 |
17 |
18 | ### 2D shapes ###
19 |
20 | def test_good_circle():
21 | try:
22 | circle = ast.Circle(5.5)
23 | except ValueError:
24 | pytest.fail()
25 | assert circle.radius == 5.5
26 |
27 |
28 | @pytest.mark.parametrize('radius', [
29 | 0, # zero radius
30 | -4.4, # negative radius
31 | ])
32 | def test_bad_circle(radius):
33 | with pytest.raises(ValueError):
34 | ast.Circle(radius)
35 |
36 |
37 | def test_good_circle_sector():
38 | try:
39 | cs = ast.CircleSector(10, 90)
40 | except ValueError:
41 | pytest.fail()
42 | assert cs.radius == 10
43 | assert cs.angle == 90
44 |
45 |
46 | @pytest.mark.parametrize(('radius', 'angle'), [
47 | [0, 90], # zero radius
48 | [-10, 90], # negative radius
49 | [10, 0], # zero angle
50 | [10, 360.5], # angle too large
51 | ])
52 | def test_bad_circle_sector(radius, angle):
53 | with pytest.raises(ValueError):
54 | ast.CircleSector(radius, angle)
55 |
56 |
57 | @pytest.mark.parametrize('radius', [
58 | 0, # zero radius
59 | -4.4, # negative radius
60 | ])
61 | def test_bad_circle(radius):
62 | with pytest.raises(ValueError):
63 | ast.Circle(radius)
64 |
65 |
66 | def test_good_rectangle():
67 | try:
68 | rectangle = ast.Rectangle(2, 4.5)
69 | except ValueError:
70 | pytest.fail()
71 | assert rectangle.width == 2
72 | assert rectangle.height == 4.5
73 |
74 |
75 | @pytest.mark.parametrize(('width', 'height'), [
76 | (0, 1), # zero width
77 | (1, 0), # zero height
78 | (-1, 1), # negative width
79 | (1, -1), # negative height
80 | ])
81 | def test_bad_rectangle(width, height):
82 | with pytest.raises(ValueError):
83 | ast.Rectangle(width, height)
84 |
85 |
86 | def test_good_polygon():
87 | points = [(0, 0), (0, 1), (1, 2), (2, 0), (0, 0)]
88 | try:
89 | polygon = ast.Polygon(points)
90 | except ValueError:
91 | pytest.fail()
92 | assert len(polygon.points) == 5
93 |
94 |
95 | @pytest.mark.parametrize('points', [
96 | [(0, 0), (1, 0), (0, 0)], # too small
97 | [(0, 0), (1, 0), (2, 3)], # not closed
98 | ])
99 | def test_bad_polygon(points):
100 | with pytest.raises(ValueError):
101 | ast.Polygon(points)
102 |
103 |
104 | ### 3D shapes ###
105 |
106 | def test_good_cube():
107 | try:
108 | cube = ast.Cube(2, 3, 4.5)
109 | except ValueError:
110 | pytest.fail()
111 | assert cube.width == 2
112 | assert cube.height == 3
113 | assert cube.depth == 4.5
114 |
115 |
116 | @pytest.mark.parametrize(('width', 'height', 'depth'), [
117 | (0, 1, 1), # zero width
118 | (1, 0, 1), # zero height
119 | (1, 1, 0), # zero depth
120 | (-1, 1, 1), # negative width
121 | (1, -1, 1), # negative height
122 | (1, 1, -1), # negative depth
123 | ])
124 | def test_bad_cube(width, height, depth):
125 | with pytest.raises(ValueError):
126 | ast.Cube(width, height, depth)
127 |
128 |
129 | def test_good_sphere():
130 | try:
131 | sphere = ast.Sphere(5.5)
132 | except ValueError:
133 | pytest.fail()
134 | assert sphere.radius == 5.5
135 |
136 |
137 | @pytest.mark.parametrize('radius', [
138 | 0, # zero radius
139 | -4.4, # negative radius
140 | ])
141 | def test_bad_sphere(radius):
142 | with pytest.raises(ValueError):
143 | ast.Sphere(radius)
144 |
145 |
146 | def test_good_cylinder():
147 | try:
148 | cylinder = ast.Cylinder(10, 3, 5.5)
149 | except ValueError:
150 | pytest.fail()
151 | assert cylinder.height == 10
152 | assert cylinder.radius1 == 3
153 | assert cylinder.radius2 == 5.5
154 |
155 |
156 | @pytest.mark.parametrize(('height', 'radius1', 'radius2'), [
157 | (0, 1, 1), # zero height
158 | (1, 0, 1), # zero radius1
159 | (1, 1, 0), # zero radius2
160 | (-1, 1, 1), # negative height
161 | (1, -1, 1), # negative radius1
162 | (1, 1, -1), # negative radius2
163 | ])
164 | def test_bad_cylinder(height, radius1, radius2):
165 | with pytest.raises(ValueError):
166 | ast.Cylinder(height, radius1, radius2)
167 |
168 |
169 | def test_good_polyhedron():
170 | try:
171 | points = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)]
172 | triangles = [(0, 1, 2), (1, 0, 3), (1, 3, 2), (0, 2, 3)]
173 | quads = [(0, 1, 2, 3)]
174 | ast.Polyhedron(points, triangles=triangles)
175 | ast.Polyhedron(points, quads=quads)
176 | polyhedron = ast.Polyhedron(points, quads=quads, triangles=triangles)
177 | except ValueError:
178 | pytest.fail()
179 | assert polyhedron.points == points
180 | assert polyhedron.triangles == triangles
181 | assert polyhedron.quads == quads
182 |
183 |
184 | @pytest.mark.parametrize(('points', 'triangles', 'quads'), [
185 | ( # Not enough points
186 | [(0, 0, 0), (1, 0, 0), (1, 1, 0)],
187 | [(0, 1, 2)], []
188 | ),
189 | ( # Neither triangles nor quads
190 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
191 | [], [],
192 | ),
193 | ( # Invalid triangles
194 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
195 | [(0, 1, 2), (1, 0, 3, 4), (1, 3, 2), (0, 2, 3)], []
196 | ),
197 | ( # Invalid quads
198 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
199 | [], [(0, 1, 2), (1, 0, 3, 4), (1, 3, 2), (0, 2, 3)]
200 | ),
201 | ( # Referenced invalid point in triangles (too large)
202 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
203 | [(0, 1, 4), (1, 0, 3), (1, 3, 2), (0, 2, 3)], []
204 | ),
205 | ( # Referenced invalid point in triangles (negative)
206 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
207 | [(0, 1, -1), (1, 0, 3), (1, 3, 2), (0, 2, 3)], []
208 | ),
209 | ( # Referenced invalid point in quads (too large)
210 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
211 | [], [(0, 1, 2, 4)]
212 | ),
213 | ( # Referenced invalid point in quads (too large)
214 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
215 | [(0, 1, 2), (0, 1, 3)], [(0, 1, 2, 4)]
216 | ),
217 | ( # Referenced invalid point in quads (negative)
218 | [(0, 0, 0), (1, 0, 0), (1, 1, 0), (1, 1, 1)],
219 | [], [(0, 1, 2, -1)]
220 | ),
221 | ])
222 | def test_bad_polyhedron(points, triangles, quads):
223 | print(triangles)
224 | print(quads)
225 | with pytest.raises(ValueError):
226 | ast.Polyhedron(points, triangles, quads)
227 |
228 |
229 | ### Transformations ###
230 |
231 | def test_good_translate():
232 | circle = ast.Circle(5.5)
233 | try:
234 | translate = ast.Translate(x=1, y=-0.5, z=0, item=circle)
235 | except ValueError:
236 | pytest.fail()
237 | assert translate.x == 1
238 | assert translate.y == -0.5
239 | assert translate.z == 0
240 | assert translate.item == circle
241 |
242 |
243 | @pytest.mark.parametrize(('x', 'y', 'z', 'item'), [
244 | (1, 0, 1, None), # no item
245 | (1, 0, 1, 'item'), # non-AST item
246 | (1, 0, 1, []), # non-AST item
247 | ])
248 | def test_bad_translate(x, y, z, item):
249 | with pytest.raises(ValueError):
250 | ast.Translate(x, y, z, item)
251 |
252 |
253 | def test_good_rotate():
254 | circle = ast.Circle(5.5)
255 | try:
256 | rotate = ast.Rotate(90, (1, 0, 0), item=circle)
257 | except ValueError:
258 | pytest.fail()
259 | assert rotate.degrees == 90
260 | assert rotate.vector == (1, 0, 0)
261 |
262 |
263 | @pytest.mark.parametrize(('degrees', 'vector', 'item'), [
264 | (30, (1, 0, 0), None), # no item
265 | (30, (1, 0, 0), 'item'), # non-AST item
266 | (30, '1, 0, 0', ast.Circle(1)), # invalid vector
267 | (30, (1, 0), ast.Circle(1)), # invalid vector
268 | (30, (0, 0, 0), ast.Circle(1)), # invalid vector
269 | (30, (2, 0, 0), ast.Circle(1)), # invalid vector
270 | (30, (0.5, 0, 0), ast.Circle(1)), # invalid vector
271 | ])
272 | def test_bad_rotate(degrees, vector, item):
273 | with pytest.raises(ValueError):
274 | ast.Rotate(degrees, vector, item)
275 |
276 |
277 | def test_good_scale():
278 | cylinder = ast.Cylinder(10, 2, 2)
279 | try:
280 | scale = ast.Scale(x=1, y=0.5, z=2, item=cylinder)
281 | except ValueError:
282 | pytest.fail()
283 | assert scale.x == 1
284 | assert scale.y == 0.5
285 | assert scale.z == 2
286 | assert scale.item == cylinder
287 |
288 |
289 | @pytest.mark.parametrize(('x', 'y', 'z', 'item'), [
290 | (1, 0.5, 2, None), # no item
291 | (1, 0.5, 2, 'item'), # non-AST item
292 | (1, 1, 0, ast.Cylinder(10, 2, 2)), # zero z value
293 | ])
294 | def test_bad_scale(x, y, z, item):
295 | with pytest.raises(ValueError):
296 | ast.Scale(x, y, z, item)
297 |
298 |
299 | def test_good_mirror():
300 | cylinder = ast.Cylinder(10, 2, 2)
301 | try:
302 | mirror = ast.Mirror([1, 0.5, 2], item=cylinder)
303 | except ValueError:
304 | pytest.fail()
305 | assert mirror.vector == (1, 0.5, 2)
306 | assert mirror.item == cylinder
307 |
308 |
309 | @pytest.mark.parametrize(('vector', 'item'), [
310 | ([1, 1, 0], None), # no item
311 | ([1, 1, 0], 'item'), # non-AST item
312 | ([0, 0, 0], ast.Sphere(1)), # invalid vector
313 | ])
314 | def test_bad_mirror(vector, item):
315 | with pytest.raises(ValueError):
316 | ast.Mirror(vector, item)
317 |
318 |
319 | ### Boolean operations ###
320 |
321 | @pytest.mark.parametrize('Cls', [ast.Union, ast.Difference, ast.Intersection])
322 | def test_good_boolean(Cls):
323 | circle1 = ast.Circle(5.5)
324 | circle2 = ast.Circle(2)
325 | try:
326 | result = Cls(items=[circle1, circle2])
327 | except ValueError:
328 | pytest.fail()
329 | assert len(result.items) == 2
330 | assert result.items[0] == circle1
331 |
332 |
333 | @pytest.mark.parametrize('Cls', [ast.Union, ast.Difference, ast.Intersection])
334 | @pytest.mark.parametrize('items', [
335 | [], # empty list
336 | 1, # non-iterable
337 | [ast.Circle(5)], # only 1 item
338 | [ast.Circle(5), 2, 3], # non-AST items
339 | ])
340 | def test_bad_boolean(Cls, items):
341 | with pytest.raises(ValueError):
342 | Cls(items)
343 |
344 |
345 | ### Extrusions ###
346 |
347 | def test_good_linear_extrusion():
348 | try:
349 | linear_extrusion = ast.LinearExtrusion(10, ast.Circle(3))
350 | except ValueError:
351 | pytest.fail()
352 | assert linear_extrusion.height == 10
353 | assert linear_extrusion.item.radius == 3
354 |
355 |
356 | @pytest.mark.parametrize('item', [
357 | None, # no item
358 | 'foo', # non-AST item
359 | ])
360 | def test_bad_linear_extrusion(item):
361 | with pytest.raises(ValueError):
362 | ast.LinearExtrusion(10, item)
363 |
364 |
365 | def test_good_rotate_extrusion():
366 | try:
367 | rotate_extrusion = ast.RotateExtrusion(ast.Circle(3))
368 | except ValueError:
369 | pytest.fail()
370 | assert rotate_extrusion.item.radius == 3
371 |
372 |
373 | @pytest.mark.parametrize('item', [
374 | None, # no item
375 | 'foo', # non-AST item
376 | ])
377 | def test_bad_rotate_extrusion(item):
378 | with pytest.raises(ValueError):
379 | ast.RotateExtrusion(item)
380 |
--------------------------------------------------------------------------------
/tests/test_backend_openscad.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible import ast
7 | from tangible.backends.openscad import OpenScadBackend as Backend
8 |
9 |
10 | def verify(shape, code):
11 | """Helper function."""
12 | assert Backend(shape).generate() == code
13 |
14 |
15 | @pytest.mark.parametrize(('shape', 'code'), [
16 | (ast.Circle(radius=10), 'circle(10);'),
17 | (ast.Rectangle(width=1, height=2), 'square([1, 2]);'),
18 | (ast.Cube(width=1, height=2, depth=3), 'cube([1, 3, 2]);'),
19 | (ast.Sphere(radius=10), 'sphere(10);'),
20 | (ast.Cylinder(height=3, radius1=1, radius2=2), 'cylinder(3, 1, 2);'),
21 | (ast.Translate(1, 2, 3, ast.Circle(1)), 'translate([1, 2, 3])\n{\n circle(1);\n};'),
22 | (ast.Rotate(30, (0, 1, 0), ast.Circle(1)), 'rotate(30, [0, 1, 0])\n{\n circle(1);\n};'),
23 | (ast.Rotate(30, [0, 1, 0], ast.Circle(1)), 'rotate(30, [0, 1, 0])\n{\n circle(1);\n};'),
24 | (ast.Mirror([0, 1, 1], ast.Circle(1)), 'mirror([0, 1, 1])\n{\n circle(1);\n};'),
25 | (ast.Union([ast.Circle(1), ast.Sphere(2)]),
26 | 'union()\n{\n circle(1);\n sphere(2);\n};'),
27 | (ast.Difference([ast.Circle(1), ast.Sphere(2)]),
28 | 'difference()\n{\n circle(1);\n sphere(2);\n};'),
29 | (ast.Intersection([ast.Circle(1), ast.Sphere(2)]),
30 | 'intersection()\n{\n circle(1);\n sphere(2);\n};'),
31 | (ast.LinearExtrusion(height=7, item=ast.Circle(1)),
32 | 'linear_extrude(7, twist=0)\n{\n circle(1);\n};'),
33 | (ast.LinearExtrusion(height=7, twist=90, item=ast.Circle(1)),
34 | 'linear_extrude(7, twist=90)\n{\n circle(1);\n};'),
35 | (ast.RotateExtrusion(ast.Circle(1)), 'rotate_extrude()\n{\n circle(1);\n};'),
36 | ])
37 | def test_simple_shapes(shape, code):
38 | verify(shape, code)
39 |
40 |
41 | def test_polygon():
42 | shape = ast.Polygon(points=[(0, 0), (0, 2), (1, 2), (0, 0)])
43 | code = 'polygon([[0, 0], [0, 2], [1, 2]]);'
44 | verify(shape, code)
45 |
46 |
47 | # TODO test_polyhedron
48 |
49 |
50 | circle_sector_module = """module circle_sector(r, a) {
51 | a1 = a % 360;
52 | a2 = 360 - (a % 360);
53 | if (a1 <= 180) {
54 | intersection() {
55 | circle(r);
56 | polygon([
57 | [0,0],
58 | [0,r],
59 | [sin(a1/2)*r, r + cos(a1/2)*r],
60 | [sin(a1)*r + sin(a1/2)*r, cos(a1)*r + cos(a1/2)*r],
61 | [sin(a1)*r, cos(a1)*r],
62 | ]);
63 | }
64 | } else {
65 | difference() {
66 | circle(r);
67 | mirror([1,0]) {
68 | polygon([
69 | [0,0],
70 | [0,r],
71 | [sin(a2/2)*r, r + cos(a2/2)*r],
72 | [sin(a2)*r + sin(a2/2)*r, cos(a2)*r + cos(a2/2)*r],
73 | [sin(a2)*r, cos(a2)*r],
74 | ]);
75 | };
76 | }
77 | }
78 | };
79 | """
80 |
81 |
82 | def test_circle_sector():
83 | shape = ast.Difference([
84 | ast.CircleSector(radius=10, angle=180),
85 | ast.CircleSector(radius=8, angle=135),
86 | ])
87 | raw_code = '%s\ndifference()\n{\n circle_sector(10, 180);\n circle_sector(8, 135);\n};'
88 | verify(shape, raw_code % circle_sector_module)
89 |
--------------------------------------------------------------------------------
/tests/test_examples.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import os
5 | import subprocess
6 | from contextlib import contextmanager
7 |
8 | import pytest
9 |
10 |
11 | @contextmanager
12 | def chdir(path):
13 | old_dir = os.getcwd()
14 | os.chdir(path)
15 | yield
16 | os.chdir(old_dir)
17 |
18 |
19 | @pytest.mark.parametrize('pyfile', [f for f in os.listdir('examples') if f.endswith('.py')])
20 | def test_returncode(pyfile):
21 | try:
22 | with chdir('examples'):
23 | subprocess.check_call('python ' + pyfile, shell=True)
24 | except subprocess.CalledProcessError:
25 | pytest.fail('Example {} returned with status code != 0.'.format(pyfile))
26 |
--------------------------------------------------------------------------------
/tests/test_mixins.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible.shapes import mixins
7 | from tangible.shapes.base import Shape
8 |
9 |
10 | data1d_flat = [1, 2, 3, 4, 5]
11 | data1d_nested = [[1, 2, 3, 4, 5]]
12 | data2d = [[1, 2, 3], [4, 5, 6]]
13 | data4d = [[1, 2], [3, 4], [5, 6], [7, 8]]
14 |
15 |
16 | @pytest.mark.parametrize(('data', 'Mixin'), [
17 | (data1d_flat, mixins.Data2DMixin),
18 | (data1d_nested, mixins.Data2DMixin),
19 | (data4d, mixins.Data2DMixin),
20 | (data1d_flat, mixins.Data4DMixin),
21 | (data1d_nested, mixins.Data4DMixin),
22 | (data2d, mixins.Data4DMixin),
23 | ])
24 | def test_dimension_mixin_fails(data, Mixin):
25 | class MyShape(Mixin, Shape):
26 | pass
27 | with pytest.raises(ValueError):
28 | MyShape(data)
29 |
30 |
31 | @pytest.mark.parametrize(('data', 'Mixin'), [
32 | (data1d_flat, mixins.Data1DMixin),
33 | (data1d_nested, mixins.Data1DMixin),
34 | (data2d, mixins.Data2DMixin),
35 | (data4d, mixins.Data4DMixin),
36 | ])
37 | def test_dimension_mixin_success(data, Mixin):
38 | class MyShape(Mixin, Shape):
39 | pass
40 | try:
41 | MyShape(data)
42 | except:
43 | pytest.fail()
44 |
45 |
46 | @pytest.mark.parametrize('data', [
47 | [[1], [2, 3]],
48 | [[1, 2], [3, 4, 5], [6, 7]],
49 | [range(200), range(200), range(201)],
50 | ])
51 | def test_same_length_dataset_mixin_fails(data):
52 | class MyShape(mixins.SameLengthDatasetMixin, Shape):
53 | pass
54 | with pytest.raises(ValueError):
55 | MyShape(data)
56 |
57 |
58 | @pytest.mark.parametrize('data', [
59 | [[1], [2]],
60 | [[1, 2], [2, 3]],
61 | [range(200), range(200), range(200)],
62 | ])
63 | def test_same_length_dataset_mixin_success(data):
64 | class MyShape(mixins.SameLengthDatasetMixin, Shape):
65 | pass
66 | try:
67 | MyShape(data)
68 | except:
69 | pytest.fail()
70 |
--------------------------------------------------------------------------------
/tests/test_scales.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible import scales
7 |
8 |
9 | @pytest.mark.parametrize(('param', 'clamp', 'expected'), [
10 | # Regular values
11 | (2, False, 10),
12 | (3, False, 15),
13 | (3.5, False, 17.5),
14 | (4, False, 20),
15 | # Clamping
16 | (6, False, 30),
17 | (6, True, 20),
18 | (-1, False, -5),
19 | (-1, True, 10),
20 | ])
21 | def test_linear(param, clamp, expected):
22 | """Test the linear scale."""
23 | domain = (2, 4)
24 | codomain = (10, 20)
25 | scale = scales.linear(domain, codomain, clamp)
26 | assert scale(param) == expected
27 |
28 |
29 | @pytest.mark.parametrize(('param', 'clamp', 'expected'), [
30 | # Regular values
31 | (1, False, 20),
32 | (3, False, 10),
33 | (2.5, False, 12.5),
34 | # Clamping
35 | (0, False, 25),
36 | (0, True, 20),
37 | (7, False, -10),
38 | (7, True, 10),
39 | ])
40 | def test_linear_inverted_codomain(param, clamp, expected):
41 | """Test the linear scale with inverted codomain."""
42 | domain = (1, 3)
43 | codomain = (20, 10)
44 | scale = scales.linear(domain, codomain, clamp)
45 | assert scale(param) == expected
46 |
--------------------------------------------------------------------------------
/tests/test_shapes.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible import shapes
7 | from tangible.shapes.base import Shape
8 |
9 |
10 | @pytest.mark.parametrize('data', [
11 | [1, 2, 3],
12 | (1, 2, 3),
13 | [[1, 2], [3, 4, 5], (6, 7)],
14 | ])
15 | def test_valid_base_shape(data):
16 | try:
17 | Shape(data)
18 | except ValueError:
19 | pytest.fail()
20 |
21 |
22 | @pytest.mark.parametrize('data', [
23 | '',
24 | None,
25 | ])
26 | def test_invalid_base_shape(data):
27 | with pytest.raises(ValueError):
28 | Shape(data)
29 |
30 |
31 | @pytest.mark.parametrize(('data', 'angle'), [
32 | (range(1), 360),
33 | (range(2), 180),
34 | (range(10), 36),
35 | (range(16), 22.5),
36 | ])
37 | def test_base_pie(data, angle):
38 | my_pie = shapes.pie.PieShape(data)
39 | assert len(my_pie.angles) == len(data), "# of angles should equal # of datapoints."
40 | assert my_pie.angles[0] == angle, "Angle should be 360/len(datapoints)."
41 | assert len(set(my_pie.angles)) == 1, "All angles should be the same."
42 |
--------------------------------------------------------------------------------
/tests/test_utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from __future__ import print_function, division, absolute_import, unicode_literals
3 |
4 | import pytest
5 |
6 | from tangible import utils, ast
7 |
8 |
9 | @pytest.mark.parametrize(('values', 'pairs'), [
10 | (range(6), [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]),
11 | ([2, 4], [(2, 4)]),
12 | ([1], []),
13 | ])
14 | def test_pairwise_list(values, pairs):
15 | assert list(utils.pairwise(values)) == pairs
16 |
17 |
18 | @pytest.mark.parametrize(('data', 'groups'), [
19 | ([1, 2, 3, 10, 11, 20, 28, 29], [6, 21, 77]),
20 | ([1], [1]),
21 | ([-1, -2, -3, 4, 5, 6], [-6, 15]),
22 | ])
23 | def test_reduceby_sum(data, groups):
24 | keyfunc = lambda x: x // 10
25 | reducefunc = lambda x, y: x + y
26 | generator = utils.reduceby(data, keyfunc, reducefunc, 0)
27 | assert list(generator) == groups
28 |
29 |
30 | @pytest.mark.parametrize(('data', 'groups'), [
31 | ([0, 2, 4, 6, 8, 10, 1, 3, 5, 7, 9], [0, 945]),
32 | ([1], [1]),
33 | ([1, 1, 1, 42], [1, 42]),
34 | ([1, 2, 3, 4], [1, 2, 3, 4]),
35 | ])
36 | def test_reduceby_product(data, groups):
37 | keyfunc = lambda x: x % 2 == 0
38 | reducefunc = lambda x, y: x * y
39 | generator = utils.reduceby(data, keyfunc, reducefunc, 1)
40 | assert list(generator) == groups
41 |
42 |
43 | @pytest.mark.parametrize(('quads', 'triangles'), [
44 | ([(0, 1, 2, 3)], [(0, 1, 2), (0, 2, 3)]),
45 | ([(1, 3, 5, 7), (6, 5, 4, 3)], [(1, 3, 5), (1, 5, 7), (6, 5, 4), (6, 4, 3)]),
46 | ])
47 | def test_quads_to_triangles(quads, triangles):
48 | assert utils._quads_to_triangles(quads) == triangles
49 |
50 |
51 | class TestCircleConnect(object):
52 |
53 | @classmethod
54 | def setup_class(cls):
55 | cls.shapes = [
56 | ast.Circle(3),
57 | ast.Circle(8),
58 | ast.Circle(5),
59 | ]
60 | cls.union = ast.Union(items=[
61 | ast.Translate(0, 0, 0, ast.Cylinder(10, 3, 8)),
62 | ast.Translate(0, 0, 10, ast.Cylinder(10, 8, 5)),
63 | ])
64 |
65 | def test_vertical(self):
66 | result = utils.connect_2d_shapes(self.shapes, 10, 'vertical')
67 | assert result == self.union
68 |
69 | def test_horizontal(self):
70 | result = utils.connect_2d_shapes(self.shapes, 10, 'horizontal')
71 | vector = [0, 1, 0]
72 | assert result == ast.Rotate(90, vector, self.union)
73 |
74 |
75 | class TestRectangleConnect(object):
76 |
77 | @classmethod
78 | def setup_class(cls):
79 | cls.shapes = [
80 | ast.Rectangle(width=6, height=6),
81 | ast.Rectangle(width=10, height=6),
82 | ast.Rectangle(width=6, height=22),
83 | ]
84 | cls.union = ast.Union(items=[
85 | ast.Translate(0, 0, 0,
86 | ast.Polyhedron(points=[
87 | [3.0, 3.0, 0], [-3.0, 3.0, 0], [-3.0, -3.0, 0], [3.0, -3.0, 0],
88 | [5.0, 3.0, 10], [-5.0, 3.0, 10], [-5.0, -3.0, 10], [5.0, -3.0, 10],
89 | ], quads=[
90 | [0, 1, 2, 3], [4, 7, 6, 5],
91 | [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0],
92 | ])
93 | ),
94 | ast.Translate(0, 0, 10,
95 | ast.Polyhedron(points=[
96 | [5.0, 3.0, 0], [-5.0, 3.0, 0], [-5.0, -3.0, 0], [5.0, -3.0, 0],
97 | [3.0, 11.0, 10], [-3.0, 11.0, 10], [-3.0, -11.0, 10], [3.0, -11.0, 10],
98 | ], quads=[
99 | [0, 1, 2, 3], [4, 7, 6, 5],
100 | [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0],
101 | ])
102 | ),
103 | ])
104 |
105 | def test_vertical(self):
106 | result = utils.connect_2d_shapes(self.shapes, 10, 'vertical')
107 | assert result == self.union
108 |
109 | def test_horizontal(self):
110 | result = utils.connect_2d_shapes(self.shapes, 10, 'horizontal')
111 | vector = [0, 1, 0]
112 | assert result == ast.Rotate(90, vector, self.union)
113 |
114 |
115 | class TestPolygonConnect(object):
116 |
117 | @classmethod
118 | def setup_class(cls):
119 | cls.shapes = [
120 | ast.Polygon(points=[(3, 0), (3, 4), (-2, 3), (-3, 0), (0, -2), (3, 0)]),
121 | ast.Polygon(points=[(3, 0), (0, 2), (-3, 0), (-2, -3), (2, -3), (3, 0)]),
122 | ast.Polygon(points=[(3, 0), (3, 4), (-2, 3), (-3, 0), (0, -2), (3, 0)]),
123 | ]
124 | quads = [[1, 0, 5, 6], [2, 1, 6, 7], [3, 2, 7, 8], [4, 3, 8, 9], [0, 4, 9, 5]]
125 | triangles = [
126 | [0, 1, 2], [7, 6, 5],
127 | [0, 2, 3], [8, 7, 5],
128 | [0, 3, 4], [9, 8, 5],
129 | ]
130 | cls.union = ast.Union(items=[
131 | ast.Translate(0, 0, 0,
132 | ast.Polyhedron(points=[
133 | [3, 0, 0], [3, 4, 0], [-2, 3, 0], [-3, 0, 0], [0, -2, 0],
134 | [3, 0, 5], [0, 2, 5], [-3, 0, 5], [-2, -3, 5], [2, -3, 5],
135 | ], quads=quads, triangles=triangles)
136 | ),
137 | ast.Translate(0, 0, 5,
138 | ast.Polyhedron(points=[
139 | [3, 0, 0], [0, 2, 0], [-3, 0, 0], [-2, -3, 0], [2, -3, 0],
140 | [3, 0, 5], [3, 4, 5], [-2, 3, 5], [-3, 0, 5], [0, -2, 5],
141 | ], quads=quads, triangles=triangles)
142 | ),
143 | ])
144 |
145 | def test_vertical_points(self):
146 | result = utils.connect_2d_shapes(self.shapes, 5, 'vertical')
147 | assert result.items[0].item.points == self.union.items[0].item.points
148 | assert result.items[1].item.points == self.union.items[1].item.points
149 |
150 | def test_vertical_triangles(self):
151 | result = utils.connect_2d_shapes(self.shapes, 5, 'vertical')
152 | assert result.items[0].item.triangles == self.union.items[0].item.triangles
153 | assert result.items[1].item.triangles == self.union.items[1].item.triangles
154 |
155 | def test_vertical_quads(self):
156 | result = utils.connect_2d_shapes(self.shapes, 5, 'vertical')
157 | assert result.items[0].item.quads == self.union.items[0].item.quads
158 | assert result.items[1].item.quads == self.union.items[1].item.quads
159 |
160 | def test_vertical(self):
161 | result = utils.connect_2d_shapes(self.shapes, 5, 'vertical')
162 | assert result == self.union
163 |
164 | def test_horizontal(self):
165 | result = utils.connect_2d_shapes(self.shapes, 5, 'horizontal')
166 | vector = [0, 1, 0]
167 | assert result == ast.Rotate(90, vector, self.union)
168 |
169 |
170 | def test_connect_heterogenous_handling():
171 | """Assert that heterogenous shapes cannot be merged."""
172 | shapes = [ast.Circle(5), ast.Rectangle(2, 3)]
173 | with pytest.raises(NotImplementedError):
174 | utils.connect_2d_shapes(shapes, 10, 'vertical')
175 |
176 |
177 | def test_connect_invalid_arguments():
178 | """Test that arguments for ``connect_2d_shapes`` are validated."""
179 | shapes = [ast.Circle(5), ast.Circle(2)]
180 | with pytest.raises(AssertionError):
181 | utils.connect_2d_shapes(shapes, 10, 'diagonal')
182 |
183 |
184 | @pytest.mark.parametrize(('data', 'result'), [
185 | ([], [[]]),
186 | ([1, 2, 3], [[1, 2, 3]]),
187 | ([[1, 2, 3]], [[1, 2, 3]]),
188 | ([[1, 2, 3], [4, 5, 6]], [[1, 2, 3], [4, 5, 6]]),
189 | ])
190 | def test_ensure_list_of_lists(data, result):
191 | assert utils._ensure_list_of_lists(data) == result
192 |
--------------------------------------------------------------------------------