├── .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 | --------------------------------------------------------------------------------