├── .github └── workflows │ ├── build-docs.yml │ └── ci.yml ├── .gitignore ├── CHANGES.md ├── LICENSE ├── README.md ├── dev-requirements.txt ├── docs ├── index.md └── reference.md ├── images ├── Makefile ├── _img.py ├── basic-shapes.py ├── basic-shapes.svg ├── circle-2.py ├── circle-2.svg ├── circle-spiral.py ├── circle-spiral.svg ├── circle-translate.py ├── circle-translate.svg ├── circle.py ├── circle.svg ├── cycle-line.py ├── cycle-line.svg ├── cycle-rect.py ├── cycle-rect.svg ├── cycle-square.py ├── cycle-square.svg ├── donut.py ├── donut.svg ├── images.yml ├── line.py ├── line.svg ├── rect-rotate.py ├── rect-rotate.svg ├── square-spiral.py ├── square-spiral.svg ├── ten-circles.py └── ten-circles.svg ├── joy.py ├── mkdocs.yml ├── runtests.sh ├── tests ├── __init__.py ├── test_basic_shapes.yml ├── test_combine.yml ├── test_ellipse.yml ├── test_joy.py ├── test_repeat.yml └── test_transforms.yml └── tutorial.ipynb /.github/workflows/build-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | build: 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: setup python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.9 15 | - name: install dependencies 16 | run: | 17 | python -m pip install -U pip 18 | python -m pip install -r dev-requirements.txt 19 | - name: build docs 20 | run: mkdocs build 21 | - name: add the docs to gh-pages and push 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | run: | 25 | git config --global user.name "${GITHUB_ACTOR}" 26 | git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" 27 | 28 | docroot=`mktemp -d` 29 | rsync -av "site/" "${docroot}/" 30 | 31 | cd "${docroot}" 32 | git init 33 | git remote add origin "https://token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" 34 | git checkout -b gh-pages 35 | touch .nojekyll 36 | git add . 37 | 38 | msg="Updating docs for commit ${GITHUB_SHA}" 39 | git commit -am "${msg}" 40 | git push origin gh-pages --force -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: {} 7 | jobs: 8 | tests: 9 | runs-on: ubuntu-20.04 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: setup python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: 3.9 16 | - name: install dependencies 17 | run: | 18 | python -m pip install -U pip 19 | python -m pip install -r dev-requirements.txt 20 | - name: run tests 21 | run: ./runtests.sh 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | .ipynb_checkpoints/ 4 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## Unreleased\ 2 | 3 | ## Version 0.3.1 - 2021-12-29 4 | * Fixed inconsistencies in rendering caused due to a bug in cloning the node (#34) 5 | 6 | ## Version 0.3.0 - 2021-10-11 7 | 8 | * Improved the repeat tranformation and fixed a couple of bugs that were introduced due to these improvements 9 | * Added new functions `combine`, `color` and `random` 10 | * Added `polyline` shape (tx @abhinavxd) 11 | * Added `polygon` shape 12 | * Added `as_dict` method to Shape to be able to serialize the shapes to JSON 13 | * Added lowercase function names for transformations. 14 | * Fixed the issues with `line` function 15 | 16 | ## Version 0.2.3 - 2021-06-19 17 | 18 | * Fixed a typo that causing the y coordinate to be initialized to the value of argument `x` instead of `y`. 19 | 20 | ## Version 0.2.2 - 2021-06-17 21 | 22 | * Added lowercase functions for shapes with simpler API 23 | 24 | ## Version 0.2.1 - 2021-06-11 25 | 26 | * Made the y value grow upwards, just like the classical cartisian 27 | coordinate system learn in the high school 28 | 29 | ## Version 0.2.0 - 2021-06-10 30 | 31 | * Refactored the entire API of the library, not backword-compatible 32 | 33 | ## Version 0.1 - 2021-06-04 34 | 35 | * First release! 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 FOSS United Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joy 2 | 3 | Joy is a tiny creative coding library in Python. 4 | 5 | ## Installation 6 | 7 | The easiest way to install it is download `joy.py` and place it in your 8 | directory. The library has no dependencies. 9 | 10 | It can be downloaded from: 11 | 12 | 13 | 14 | ## Coordinate System 15 | 16 | Joy uses a canvas with `(0, 0)` as the center of the canvas. 17 | 18 | By default, the size of the canvas is `(300, 300)`. 19 | 20 | ## Using Joy 21 | 22 | The `Joy` library integrates well with Jupyter environment and it is 23 | recommended to explore Joy in a Jupyter lab. 24 | 25 | The first thing you need to do is import the module. 26 | 27 | ```python 28 | from joy import * 29 | ``` 30 | 31 | Once the functionality in the module is imported, you can start playing 32 | with it. 33 | 34 | ## Basic Shapes 35 | 36 | Joy supports the basic shapes `circle`, `ellipse`, `rectangle` and `line`. 37 | 38 | Let's start with a drawing a circle: 39 | 40 | ``` 41 | c = circle() 42 | show(c) 43 | ``` 44 | 45 | ![svg](images/circle.svg) 46 | 47 | By default circle will have center at `(0, 0)` and radius as `100`. But 48 | you can specify different values. 49 | 50 | ``` 51 | c = circle(x=50, y=50, r=50) 52 | show(c) 53 | ``` 54 | 55 | ![svg](images/circle-2.svg) 56 | 57 | The other basic types that are supported are `ellipse`, `rectangle`, 58 | and `line`: 59 | 60 | ``` 61 | s1 = circle() 62 | s2 = ellipse() 63 | s3 = rectangle() 64 | s4 = line() 65 | show(s1, s2, s3, s4) 66 | ``` 67 | 68 | ![svg](images/basic-shapes.svg) 69 | 70 | ## Combining Shapes 71 | 72 | Joy supports `+` operator to join shapes. 73 | 74 | ``` 75 | def donut(x, y, r): 76 | c1 = circle(x=x, y=y, r=r) 77 | c2 = circle(x=x, y=y, r=r/2) 78 | return c1+c2 79 | 80 | d = donut(0, 0, 100) 81 | show(d) 82 | ``` 83 | 84 | ![svg](images/donut.svg) 85 | 86 | 87 | ## Transformations 88 | 89 | Joy supports `translate`, `rotate` and `scale` transformations. 90 | Transformations are applied using `|` operator. 91 | 92 | ``` 93 | shape = circle(r=50) | translate(x=100, y=0) 94 | show(shape) 95 | ``` 96 | 97 | ![svg](images/circle-translate.svg) 98 | 99 | Transformations can be chained too. 100 | 101 | ``` 102 | r1 = rectangle(w=200, h=200) 103 | r2 = r1 | rotate(angle=45) | scale(1/SQRT2) 104 | show(r1, r2) 105 | ``` 106 | ![svg](images/rect-rotate.svg) 107 | 108 | ## Higer-Order Transformations 109 | 110 | Joy supports higher-order transformation `repeat`. 111 | 112 | The `repeat` transformation applies a transformation multiple times and 113 | combines all the resulting shapes. 114 | 115 | For example, draw 10 circles: 116 | 117 | ``` 118 | c = circle(x=-100, y=0, r=50) 119 | shape = c | Repeat(10, Translate(x=10, y=0) 120 | show(shape) 121 | ``` 122 | 123 | ![svg](images/ten-circles.svg) 124 | 125 | Combined with rotation, it can create amusing patterns. 126 | 127 | ``` 128 | shape = line() | repeat(18, rotate(angle=10)) 129 | show(shape) 130 | ``` 131 | 132 | ![svg](images/cycle-line.svg) 133 | 134 | 135 | We could do the same with a square: 136 | 137 | ``` 138 | shape = rectangle(w=200, h=200) | repeat(18, rotate(angle=10)) 139 | show(shape) 140 | ``` 141 | 142 | ![svg](images/cycle-square.svg) 143 | 144 | or a rectangle: 145 | 146 | ``` 147 | shape = rectangle(w=200, h=100) | repeat(18, rotate(angle=10)) 148 | show(shape) 149 | ``` 150 | 151 | ![svg](images/cycle-rect.svg) 152 | 153 | We can combine multiple transformations and repeat. 154 | 155 | ``` 156 | shape = rectangle(w=300, h=300) | repeat(72, rotate(360/72) | scale(0.92)) 157 | show(shape) 158 | ``` 159 | 160 | ![svg](images/square-spiral.svg) 161 | 162 | You can try the same with a circle too: 163 | 164 | ``` 165 | c = circle(x=100, y=0, radius=50) 166 | shape = c | repeat(36*4, rotate(10) | scale(0.97)) 167 | show(shape) 168 | ``` 169 | ![svg](images/circle-spiral.svg) 170 | 171 | For more information, please checkout the [tutorial](tutorial.ipynb). 172 | 173 | ## Tutorial 174 | 175 | See [tutorial.ipynb](tutorial.ipynb). 176 | 177 | ## Acknowledgements 178 | 179 | Special thanks to Amit Kapoor (@amitkaps). This library woundn't have 180 | been possible without his inputs. 181 | 182 | The long discussions between @anandology and @amitkaps on functional 183 | programming and computational artistry (for almost over an year) and the 184 | [initial experiments](https://amitkaps.com/artistry) were some of the 185 | seeds that gave life to this library. 186 | 187 | ## License 188 | 189 | This repository has been released under the MIT License. 190 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs-material 2 | pytest 3 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Joy 2 | 3 | Joy is a tiny creative coding library in Python. 4 | 5 | **Documentation:** 6 | 7 | **Source Code:** 8 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # Reference 2 | 3 | TODO -------------------------------------------------------------------------------- /images/Makefile: -------------------------------------------------------------------------------- 1 | SOURCES=$(wildcard [a-z]*.py) 2 | TARGETS=$(SOURCES:%.py=%.svg) 3 | 4 | .PHONY: default 5 | default: build 6 | 7 | .PHONY: build 8 | build: $(TARGETS) 9 | 10 | %.svg: %.py 11 | PYTHONPATH=.. python $< > $@ 12 | 13 | clean: 14 | -rm $(TARGETS) -------------------------------------------------------------------------------- /images/_img.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | 3 | def render(*shapes): 4 | """Renders the shapes as svg and prints them. 5 | """ 6 | bg = Group([ 7 | Rectangle(width=300, height=300, fill="white", stroke="#ddd"), 8 | Line(start=Point(x=-150, y=0), end=Point(x=150, y=0), stroke="#ddd"), 9 | Line(start=Point(y=-150, x=0), end=Point(y=150, x=0), stroke="#ddd"), 10 | ]) 11 | 12 | shape = Group( 13 | [bg, *shapes], 14 | stroke_width=2 # increase the stroke-width to compensate for scaling 15 | ) 16 | shape = shape | Scale(0.5) 17 | svg = SVG([shape], width=150, height=150) 18 | print(svg) -------------------------------------------------------------------------------- /images/basic-shapes.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | s1 = Circle() 5 | s2 = Ellipse() 6 | s3 = Rectangle() 7 | s4 = Line() 8 | render(s1, s2, s3, s4) 9 | -------------------------------------------------------------------------------- /images/basic-shapes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /images/circle-2.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | c = Circle(center=Point(x=50, y=50), radius=50) 5 | render(c) 6 | -------------------------------------------------------------------------------- /images/circle-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/circle-spiral.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | c = Circle(center=Point(x=100, y=0), radius=50) 5 | shape = c | Repeat(36*4, Rotate(10) | Scale(0.97)) 6 | render(shape) 7 | 8 | -------------------------------------------------------------------------------- /images/circle-translate.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | c = Circle(radius=50) | Translate(x=100, y=0) 5 | render(c) 6 | -------------------------------------------------------------------------------- /images/circle-translate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/circle.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | c = Circle() 5 | render(c) 6 | -------------------------------------------------------------------------------- /images/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/cycle-line.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | shape = Line() | Repeat(18, Rotate(10)) 5 | render(shape) -------------------------------------------------------------------------------- /images/cycle-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /images/cycle-rect.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | shape = Rectangle(width=200, height=100) | Repeat(18, Rotate(10)) 5 | render(shape) -------------------------------------------------------------------------------- /images/cycle-rect.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /images/cycle-square.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | shape = Rectangle(width=200, height=200) | Repeat(18, Rotate(10)) 5 | render(shape) -------------------------------------------------------------------------------- /images/cycle-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /images/donut.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | def donut(x, y, r): 5 | c1 = Circle(center=Point(x=x, y=y), radius=r) 6 | c2 = Circle(center=Point(x=x, y=y), radius=r/2) 7 | return c1+c2 8 | 9 | d = donut(0, 0, 100) 10 | render(d) 11 | -------------------------------------------------------------------------------- /images/donut.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /images/images.yml: -------------------------------------------------------------------------------- 1 | circle: | 2 | circle() 3 | 4 | -------------------------------------------------------------------------------- /images/line.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | s = Line() 5 | render(s) 6 | -------------------------------------------------------------------------------- /images/line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /images/rect-rotate.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | r1 = Rectangle(width=200, height=200) 5 | r2 = r1 | Rotate(angle=45) | Scale(1/SQRT2) 6 | render(r1, r2) 7 | -------------------------------------------------------------------------------- /images/rect-rotate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /images/square-spiral.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | shape = Rectangle(width=300, height=300) | Repeat(72, Rotate(360/72) | Scale(0.92)) 5 | render(shape) 6 | -------------------------------------------------------------------------------- /images/square-spiral.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /images/ten-circles.py: -------------------------------------------------------------------------------- 1 | from joy import * 2 | from _img import render 3 | 4 | c = Circle(center=Point(x=-100, y=0), radius=50) 5 | shape = c | Repeat(10, Translate(x=20, y=0)) 6 | render(shape) 7 | -------------------------------------------------------------------------------- /images/ten-circles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /joy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Joy 3 | === 4 | 5 | Joy is a tiny creative coding library in Python. 6 | 7 | BASIC USAGE 8 | 9 | An example of using joy: 10 | 11 | >>> from joy import * 12 | >>> 13 | >>> c = circle(x=100, y=100, r=50) 14 | >>> show(c) 15 | 16 | The `circle` function creates a new circle and the `show` function 17 | displays it. 18 | 19 | PRINCIPLES 20 | 21 | Joy follows functional programming approach for its interface. Each 22 | function/class gives a shape and those shapes can be transformed and 23 | combined using other utility functions. 24 | 25 | By design, there is no global state in the library. 26 | 27 | Joy uses SVG to render the shapes and the shapes are really a very thin 28 | wrapper over SVG nodes. It is possible to use every functionality of SVG, 29 | even if that is not exposed in the API. 30 | 31 | COORDINATE SYSTEM 32 | 33 | Joy uses a canvas with (0, 0) as the center of the canvas. 34 | 35 | By default the size of the canvas is (300, 300). 36 | 37 | BASIC SHAPES 38 | 39 | Joy supports `circle`, `rectangle` and `line` as basic shapes. 40 | 41 | >>> c = circle(x=100, y=100, r=50) 42 | >>> r = rectangle(x=0, y=0, w=200, h=200) 43 | >>> show(c, r) 44 | 45 | All basic shapes have default values of all the arguments, making it 46 | easier to start using them. 47 | 48 | >>> c = circle() 49 | >>> r = rectangle() 50 | >>> z = line() 51 | >>> show(c, r, z) 52 | 53 | COMBINING SHAPES 54 | 55 | The `+` operator is used to combine multiple shapes into a 56 | single shape. 57 | 58 | >>> shape = circle() + rectangle() 59 | >>> show(shape) 60 | 61 | TRANSFORMATIONS 62 | 63 | Joy supports `translate`, `rotate` and `scale` transformations. 64 | 65 | The `translate` transformation moves the given shape by `x` and `y`. 66 | 67 | >>> c1 = circle(r=50) 68 | >>> c2 = c1 | translate(x=100, y=0) 69 | >>> show(c1, c2) 70 | 71 | As you've seen the above example, transformations are applied using 72 | the `|` operator. 73 | 74 | The `Rotate` transformation rotates a shape anti-clockwise by the specified 75 | angle. 76 | 77 | >>> shape = rectangle() | rotate(angle=45) 78 | >>> show(shape) 79 | 80 | The `Scale` transformation scales a shape. 81 | 82 | >>> shape = circle() | scale(x=1, y=0.5) 83 | >>> show(shape) 84 | 85 | HIGER ORDER TRANSFORMATIONS 86 | 87 | Joy supports a transform called `repeat` to apply a transformation multiple times 88 | and combining all the resulting shapes. 89 | 90 | >>> flower = rectangle() | repeat(18, rotate(10)) 91 | >>> show(flower) 92 | 93 | JUPYTER LAB INTEGRATION 94 | 95 | Joy integrates very well with Jupyter notebooks and every shape is 96 | represented as SVG image by jupyter. 97 | """ 98 | import html 99 | import itertools 100 | import random as random_module 101 | import string 102 | 103 | __version__ = "0.3.1" 104 | __author__ = "Anand Chitipothu " 105 | 106 | SQRT2 = 2**0.5 107 | 108 | # Random suffix to avoid conflicts between ids of multiple sketches in the same page 109 | ID_SUFFIX = "".join(random_module.choice(string.ascii_letters+string.digits) for i in range(4)) 110 | 111 | def shape_sequence(): 112 | return (f"s-{i}-{ID_SUFFIX}" for i in itertools.count()) 113 | 114 | shape_seq = shape_sequence() 115 | 116 | class Shape: 117 | """Shape is the base class for all shapes in Joy. 118 | 119 | A Shape is an SVG node and supports converting itself into svg text. 120 | 121 | Typically, users do not interact with this class directly, but use it 122 | through its subclasses. 123 | """ 124 | def __init__(self, tag, children=None, transform=None, **attrs): 125 | """Creates a new shape. 126 | """ 127 | self.tag = tag 128 | self.children = children 129 | self.attrs = attrs 130 | self.transform = None 131 | 132 | def get_reference(self): 133 | if not "id" in self.attrs: 134 | self.attrs["id"] = next(shape_seq) 135 | 136 | attrs = {"xlink:href": "#" + self.id} 137 | return Shape("use", **attrs) 138 | 139 | def __repr__(self): 140 | return f"<{self.tag} {self.attrs}>" 141 | 142 | def __getattr__(self, name): 143 | if not name.startswith("_") and name in self.attrs: 144 | return self.attrs[name] 145 | else: 146 | raise AttributeError(name) 147 | 148 | def apply_transform(self, transform): 149 | if self.transform is not None: 150 | transform = self.transform | transform 151 | 152 | shape = self.clone() 153 | shape.transform = transform 154 | return shape 155 | 156 | def clone(self): 157 | shape = object.__new__(self.__class__) 158 | shape.__dict__.update(self.__dict__) 159 | 160 | # don't share attrs on clone 161 | # also remove the id as the new nodes gets a new id 162 | shape.attrs = dict(self.attrs) 163 | shape.attrs.pop("id", None) 164 | return shape 165 | 166 | def get_attrs(self): 167 | attrs = dict(self.attrs) 168 | if self.transform: 169 | attrs['transform'] = self.transform.as_str() 170 | return attrs 171 | 172 | def as_dict(self): 173 | d = self.get_attrs() 174 | d['tag'] = self.tag 175 | if self.children: 176 | d['children'] = [n.as_dict() for n in self.children] 177 | return d 178 | 179 | def _svg(self, indent="") -> str: 180 | """Returns the svg representation of this node. 181 | 182 | This method is used to recursively construct the svg of a node 183 | and it's children. 184 | 185 | >>> c = Shape(tag='circle', cx=100, cy=100, r=50) 186 | >>> c._svg() 187 | '' 188 | """ 189 | attrs = self.get_attrs() 190 | if self.children: 191 | tag_text = render_tag(self.tag, **attrs, close=False) 192 | return ( 193 | indent + tag_text + "\n" + 194 | "".join(c._svg(indent + " ") for c in self.children) + 195 | indent + "\n" 196 | ) 197 | else: 198 | tag_text = render_tag(self.tag, **attrs, close=True) 199 | return indent + tag_text + "\n" 200 | 201 | def as_svg(self, width=300, height=300) -> str: 202 | """Renders this node as svg image. 203 | 204 | The svg image is assumed to be of size (300, 300) unless the 205 | width and the height arguments are provided. 206 | 207 | Example: 208 | 209 | >>> c = Shape(tag='circle', cx=100, cy=100, r=50) 210 | >>> print(c.as_svg()) 211 | 212 | 213 | 214 | """ 215 | return SVG([self], width=width, height=height).render() 216 | 217 | def __add__(self, shape): 218 | if not isinstance(shape, Shape): 219 | return NotImplemented 220 | return Group([self, shape]) 221 | 222 | def _repr_svg_(self): 223 | """Returns the svg representation of this node. 224 | 225 | This method is called by Juputer to render this object as an 226 | svg image. 227 | """ 228 | return self.as_svg() 229 | 230 | class SVG: 231 | """SVG renders any svg element into an svg image. 232 | """ 233 | def __init__(self, nodes, width=300, height=300): 234 | self.nodes = nodes 235 | self.width = width 236 | self.height = height 237 | 238 | def render(self): 239 | attrs = { 240 | "tag": "svg", 241 | "width": self.width, 242 | "height": self.height, 243 | "viewBox": f"-{self.width//2} -{self.height//2} {self.width} {self.height}", 244 | "fill": "none", 245 | "stroke": "black", 246 | "xmlns": "http://www.w3.org/2000/svg", 247 | "xmlns:xlink": "http://www.w3.org/1999/xlink" 248 | } 249 | svg_header = render_tag(**attrs)+ "\n" 250 | svg_footer = "\n" 251 | 252 | # flip the y axis so that y grows upwards 253 | node = Group(self.nodes) | Scale(sx=1, sy=-1) 254 | 255 | return svg_header + node._svg() + svg_footer 256 | 257 | def _repr_svg_(self): 258 | return self.render() 259 | 260 | def __str__(self): 261 | return self.render() 262 | 263 | def __repr__(self): 264 | return "SVG:{self.nodes}" 265 | 266 | class Point: 267 | """Creates a new Point. 268 | 269 | Point represents a point in the coordinate space and it contains 270 | attributes x and y. 271 | 272 | >>> p = Point(x=100, y=50) 273 | """ 274 | def __init__(self, x, y): 275 | self.x = x 276 | self.y = y 277 | 278 | def __eq__(self, p): 279 | return isinstance(p, Point) \ 280 | and p.x == self.x \ 281 | and p.y == self.y 282 | 283 | def __repr__(self): 284 | return f"Point({self.x}, {self.y})" 285 | 286 | class Circle(Shape): 287 | """Creates a circle shape. 288 | 289 | Parameters: 290 | center: 291 | The center point of the circle. 292 | Defaults to Point(0, 0) when not specified. 293 | 294 | radius: 295 | The radius of the circle. 296 | Defaults to 100 when not specified. 297 | 298 | Examples: 299 | 300 | Draw a circle. 301 | 302 | >>> c = Circle() 303 | >>> show(c) 304 | 305 | Draw a Circle with radius 50. 306 | 307 | >>> c = Circle(radius=50) 308 | >>> show(c) 309 | 310 | Draw a circle with center at (100, 100) and radius as 50. 311 | 312 | >>> c = Circle(center=Point(x=100, y=100), radius=50) 313 | >>> show(c) 314 | 315 | When no arguments are specified, it uses (0, 0) as the center and 316 | 100 as the radius. 317 | """ 318 | def __init__(self, center=Point(0, 0), radius=100, **kwargs): 319 | self.center = center 320 | self.radius = radius 321 | 322 | cx, cy = self.center.x, self.center.y 323 | super().__init__("circle", 324 | cx=cx, 325 | cy=cy, 326 | r=self.radius, 327 | **kwargs) 328 | 329 | class Ellipse(Shape): 330 | """Creates an ellipse shape. 331 | 332 | Parameters: 333 | center: 334 | The center point of the ellipse. Defaults to (0, 0) when 335 | not specified. 336 | 337 | width: 338 | The width of the ellipse. Defaults to 100 when not 339 | specified. 340 | 341 | height: 342 | The height of the ellipse. Defaults to 100 when not 343 | specified. 344 | 345 | Examples: 346 | 347 | Draw an ellipse with center at origin and width of 200 and height of 100: 348 | 349 | >>> r = Ellipse() 350 | >>> show(r) 351 | 352 | Draw an ellipse having a width of 100 and a height of 50. 353 | 354 | >>> r = Ellipse(width=100, height=50) 355 | >>> show(r) 356 | 357 | Draw an ellipse centered at (100, 100) and with a width 358 | of 200 and height of 100. 359 | 360 | >>> r = Ellipse(center=Point(x=100, y=100), width=200, height=100) 361 | >>> show(r) 362 | """ 363 | def __init__(self, center=Point(0, 0), width=200, height=100, **kwargs): 364 | self.center = center 365 | self.width = width 366 | self.height = height 367 | 368 | cx, cy = self.center.x, self.center.y 369 | rx = width/2 370 | ry = height/2 371 | super().__init__( 372 | tag="ellipse", 373 | cx=cx, 374 | cy=cy, 375 | rx=rx, 376 | ry=ry, 377 | **kwargs) 378 | 379 | class Rectangle(Shape): 380 | """Creates a rectangle shape. 381 | 382 | Parameters: 383 | center: 384 | The center point of the rectangle. Defaults to (0, 0) when 385 | not specified. 386 | 387 | width: 388 | The width of the rectangle. Defaults to 200 when not 389 | specified. 390 | 391 | height: 392 | The height of the rectangle. Defaults to 100 when not 393 | specified. 394 | 395 | Examples: 396 | 397 | Draw a rectangle: 398 | 399 | >>> r = Rectangle() 400 | >>> show(r) 401 | 402 | Draw a square. 403 | 404 | >>> r = Rectangle(width=200, height=200) 405 | >>> show(r) 406 | 407 | Draw a rectangle centered at (100, 100) and with a width 408 | of 200 and height of 100. 409 | 410 | >>> r = Rectangle(center=Point(x=100, y=100), width=200, height=100) 411 | >>> show(r) 412 | """ 413 | def __init__(self, center=Point(0, 0), width=200, height=100, **kwargs): 414 | self.center = center 415 | self.width = width 416 | self.height = height 417 | 418 | cx, cy = self.center.x, self.center.y 419 | x = cx - width/2 420 | y = cy - height/2 421 | super().__init__( 422 | tag="rect", 423 | x=x, 424 | y=y, 425 | width=width, 426 | height=height, 427 | **kwargs) 428 | 429 | class Line(Shape): 430 | """Basic shape for drawing a line connecting two points. 431 | 432 | Parameters: 433 | start: 434 | The starting point of the line. Defaults to (-100, 0) when 435 | not specified. 436 | 437 | end: 438 | The ending point of the line. Defaults to (100, 0) when not 439 | specified. 440 | 441 | Examples: 442 | 443 | Draw a line: 444 | 445 | >>> z = line() 446 | >>> show(z) 447 | 448 | Draw a line from (0, 0) to (100, 50). 449 | 450 | >>> z = line(start=Point(x=0, y=0), end=Point(x=100, y=50)) 451 | >>> show(z) 452 | """ 453 | def __init__(self, start=Point(-100, 0), end=Point(100, 0), **kwargs): 454 | self.start = start 455 | self.end = end 456 | 457 | x1, y1 = self.start.x, self.start.y 458 | x2, y2 = self.end.x, self.end.y 459 | 460 | super().__init__("line", x1=x1, y1=y1, x2=x2, y2=y2, **kwargs) 461 | 462 | class Group(Shape): 463 | """Creates a container to group a list of shapes. 464 | 465 | This class is not meant for direct consumption of the users. Users 466 | are recommended to use `combine` to combine multiple shapes and use 467 | `translate`, `rotate` and `scale` for doing transformations. 468 | 469 | This creates an svg element. 470 | 471 | Parameters: 472 | shapes: 473 | The list of shapes to group. 474 | 475 | Examples: 476 | 477 | Combine a circle and a rectangle. 478 | 479 | >> c = Circle() 480 | >> r = Rectangle() 481 | >>> shape = Group([c, r]) 482 | >>> show(shape) 483 | 484 | Shapes can also be combined using the + operator and that creates 485 | a group implicitly. 486 | 487 | >>> shape = Circle() + Rectangle() 488 | >>> show(shape) 489 | """ 490 | def __init__(self, shapes, **kwargs): 491 | super().__init__("g", children=shapes, **kwargs) 492 | 493 | def render_tag(tag, *, close=False, **attrs): 494 | """Renders a html/svg tag. 495 | 496 | >>> render_tag("circle", cx=0, cy=0, r=10) 497 | '' 498 | 499 | When `close=True`, the tag is closed with "/>". 500 | 501 | >>> render_tag("circle", cx=0, cy=0, r=10, close=True) 502 | '' 503 | 504 | Underscore characters in the attribute name are replaced with hypens. 505 | 506 | >>> render_tag("circle", cx=0, cy=0, r=10, stroke_width=2) 507 | '' 508 | """ 509 | end = " />" if close else ">" 510 | 511 | if attrs: 512 | items = [(k.replace("_", "-"), html.escape(str(v))) for k, v in attrs.items() if v is not None] 513 | attrs_text = " ".join(f'{k}="{v}"' for k, v in items) 514 | 515 | return f"<{tag} {attrs_text}{end}" 516 | else: 517 | return f"<{tag}{end}" 518 | 519 | class Transformation: 520 | def apply(self, shape): 521 | return shape.apply_transform(self) 522 | 523 | def join(self, transformation): 524 | return TransformationList([self, transformation]) 525 | 526 | def __or__(self, right): 527 | if not isinstance(right, Transformation): 528 | return NotImplemented 529 | return self.join(transformation=right) 530 | 531 | def __ror__(self, left): 532 | if not isinstance(left, Shape): 533 | return NotImplemented 534 | return self.apply(shape=left) 535 | 536 | class TransformationList(Transformation): 537 | def __init__(self, transformations): 538 | self.transformations = transformations 539 | 540 | def join(self, transformation): 541 | return TransformationList(self.transformations + [transformation]) 542 | 543 | def as_str(self): 544 | # Reversing the transformations as SVG applies them in the 545 | # reverse order (the rightmost is appled first) 546 | return " ".join(t.as_str() for t in self.transformations[::-1]) 547 | 548 | class Translate(Transformation): 549 | """Creates a new Translate transformation that moves a shape by 550 | x and y when applied. 551 | 552 | Parameters: 553 | x: 554 | The number of units to move in the x direction 555 | 556 | y: 557 | The number of units to move in the y direction 558 | 559 | Example: 560 | 561 | Translate a circle by (100, 50). 562 | 563 | >>> c = Circle() | Translate(100, 50) 564 | >>> show(c) 565 | """ 566 | def __init__(self, x, y): 567 | self.x = x 568 | self.y = y 569 | 570 | def as_str(self): 571 | return f"translate({self.x} {self.y})" 572 | 573 | class Rotate(Transformation): 574 | """Creates a new rotate transformation. 575 | 576 | When applied to a shape, it rotates the given shape by angle, around 577 | the anchor point. 578 | 579 | Parameters: 580 | 581 | angle: 582 | The angle to rotate the shape in degrees. 583 | 584 | anchor: 585 | The anchor point around which the rotation is performed. 586 | 587 | Examples: 588 | 589 | Rotates a square by 45 degrees. 590 | 591 | >>> shape = Rectangle() | Rotate(angle=45) 592 | >>> show(shape) 593 | 594 | Rotate a rectangle around its top-left corner and 595 | combine with itself. 596 | 597 | >>> r1 = Rectangle() 598 | >>> r2 = r1 | Rotate(angle=45, anchor=(r.point[0])) 599 | >>> shape = combine(r1, r2) 600 | >>> show(shape) 601 | """ 602 | def __init__(self, angle, anchor=Point(0, 0)): 603 | self.angle = angle 604 | self.anchor = anchor 605 | 606 | def as_str(self): 607 | origin = Point(0, 0) 608 | if self.anchor == origin: 609 | return f"rotate({self.angle})" 610 | else: 611 | return f"rotate({self.angle} {self.anchor.x} {self.anchor.y})" 612 | 613 | class Scale(Transformation): 614 | """Creates a new scale transformation. 615 | 616 | Parameters: 617 | sx: 618 | The scale factor in the x direction. 619 | 620 | sy: 621 | The scale factor in the y direction. Defaults to 622 | the value of sx if not provided. 623 | """ 624 | def __init__(self, sx, sy=None): 625 | if sy is None: 626 | sy = sx 627 | self.sx = sx 628 | self.sy = sy 629 | 630 | def as_str(self): 631 | return f"scale({self.sx} {self.sy})" 632 | 633 | class Repeat(Transformation): 634 | """Repeat is a higher-order transformation that repeats a 635 | transformation multiple times. 636 | 637 | Parameters: 638 | n: 639 | The number of times to rotate. This also determines the 640 | angle of each rotation, which will be 360/n. 641 | 642 | transformation: 643 | The transformation to apply repeatedly. 644 | 645 | Examples: 646 | 647 | Draw three circles: 648 | 649 | >>> shape = Circle(radius=25) | Repeat(4, Translate(x=50, y=0)) 650 | >>> show(shape) 651 | 652 | Rotate a line multiple times: 653 | 654 | >>> shape = Line() | Repeat(36, Rotate(angle=10)) 655 | >>> show(shape) 656 | 657 | Rotate and shrink a line multiple times: 658 | 659 | >>> shape = Line() | Repeat(18, Rotate(angle=10) | Scale(sx=0.9)) 660 | >>> show(shape) 661 | """ 662 | def __init__(self, n, transformation): 663 | self.n = n 664 | self.transformation = transformation 665 | 666 | def apply(self, shape): 667 | ref = shape.get_reference() 668 | defs = Shape("defs", children=[shape]) 669 | 670 | return defs + self._apply(ref, self.transformation, self.n) 671 | 672 | def _apply(self, shape, tf, n): 673 | if n == 1: 674 | return shape 675 | else: 676 | result = self._apply(shape, tf, n-1) | tf 677 | return shape + result 678 | 679 | class Cycle(Transformation): 680 | """ 681 | Rotates the given shape repeatedly and combines all the resulting 682 | shapes. 683 | 684 | The cycle function is very amazing transformation and it creates 685 | surprising patterns. 686 | 687 | Parameters: 688 | n: 689 | The number of times to rotate. This also determines the 690 | angle of each rotation, which will be 360/n. 691 | 692 | anchor: 693 | The anchor point for the rotation. Defaults to (0, 0) when 694 | not specified. 695 | 696 | s: 697 | Optional scale factor to scale the shape for each rotation. 698 | This can be used to grow or shrink the shape while rotating. 699 | 700 | angle: 701 | Optional angle of rotation. Defaults to 360/n when not 702 | specified, 703 | Examples: 704 | 705 | Cycle a line: 706 | 707 | >>> shape = Line() | Cycle() 708 | >>> show(shape) 709 | 710 | Cycle a square: 711 | 712 | >>> shape = Rectangle() | Cycle() 713 | >>> show(shape) 714 | 715 | Cycle a rectangle: 716 | 717 | >>> shape = Rectangle(width=200, height=100) | Cycle() 718 | >>> show(shape) 719 | 720 | Cycle an ellipse: 721 | 722 | >>> e = scale(Circle(), sx=1, sy=0.5) 723 | >>> show(e | Cycle()) 724 | 725 | Create a spiral with shrinking squares: 726 | 727 | >>> shape = Rectangle(width=300, height=300) | cycle(n=72, s=0.92) 728 | >>> show(shape) 729 | """ 730 | def __init__(self, n=18, anchor=Point(x=0, y=0), s=None, angle=None): 731 | self.n = n 732 | self.angle = angle if angle is not None else 360/n 733 | self.anchor = anchor 734 | self.s = s 735 | 736 | def apply(self, shape): 737 | shapes = [shape | Rotate(angle=i*self.angle, anchor=self.anchor) for i in range(self.n)] 738 | if self.s is not None: 739 | shapes = [shape_ | Scale(sx=self.s**i) for i, shape_ in enumerate(shapes)] 740 | return Group(shapes) 741 | 742 | def show(*shapes): 743 | """Shows the given shapes. 744 | 745 | It also adds a border to the canvas and axis at the origin with 746 | a light color as a reference. 747 | 748 | Parameters: 749 | 750 | shapes: 751 | The shapes to show. 752 | 753 | Examples: 754 | 755 | Show a circle: 756 | 757 | >>> show(circle()) 758 | 759 | Show a circle and square. 760 | 761 | >>> c = circle() 762 | >>> s = rect() 763 | >>> show(c, s) 764 | """ 765 | markers = [ 766 | Rectangle(width=300, height=300, stroke="#ddd"), 767 | Line(start=Point(x=-150, y=0), end=Point(x=150, y=0), stroke="#ddd"), 768 | Line(start=Point(x=0, y=-150), end=Point(x=0, y=150), stroke="#ddd") 769 | ] 770 | shapes = markers + list(shapes) 771 | img = SVG(shapes) 772 | 773 | from IPython.display import display 774 | display(img) 775 | 776 | def circle(x=0, y=0, r=100, **kwargs): 777 | """Creates a circle with center at (x, y) and radius of r. 778 | 779 | Examples: 780 | 781 | Draw a circle. 782 | 783 | c = circle() 784 | show(c) 785 | 786 | Draw a circle with radius 50. 787 | 788 | c = circle(r=50) 789 | show(c) 790 | 791 | Draw a circle with center at (10, 20) and a radius of 50. 792 | 793 | c = circle(x=10, y=20, r=50) 794 | show(c) 795 | """ 796 | return Circle(center=Point(x=x, y=y), radius=r, **kwargs) 797 | 798 | def rectangle(x=0, y=0, w=200, h=100, **kwargs): 799 | """Creates a rectangle with center at (x, y), a width of w and a height of h. 800 | 801 | Examples: 802 | 803 | Draw a rectangle. 804 | 805 | r = rectangle() 806 | show(r) 807 | 808 | Draw a rectangle with width of 100 and height of 50. 809 | 810 | r = rectangle(w=100, h=50) 811 | show(r) 812 | 813 | Draw a rectangle with center at (10, 20), a width of 100 and a height of 50. 814 | 815 | r = rectangle(x=10, y=20, w=100, h=50) 816 | show(r) 817 | """ 818 | return Rectangle(center=Point(x=x, y=y), width=w, height=h, **kwargs) 819 | 820 | def ellipse(x=0, y=0, w=200, h=100, **kwargs): 821 | """Creates an ellipse with center at (x, y), a width of w and a height of h. 822 | 823 | Examples: 824 | 825 | Draw an ellipse. 826 | 827 | r = ellipse() 828 | show(r) 829 | 830 | Draw an ellipse with a width of 100 and height of 50. 831 | 832 | r = ellipse(w=100, h=50) 833 | show(r) 834 | 835 | Draw an ellipse with center at (10, 20), a width of 100 and a height of 50. 836 | 837 | r = ellipse(x=10, y=20, w=100, h=50) 838 | show(r) 839 | """ 840 | return Ellipse(center=Point(x=x, y=y), width=w, height=h, **kwargs) 841 | 842 | def line(x1=None, y1=None, x2=None, y2=None, **kwargs): 843 | """Creates a line from point (x1, y1) to point (x2, y2). 844 | 845 | Examples: 846 | 847 | Draw a line. 848 | 849 | z = line() 850 | 851 | Draw a line from (10, 20) to (100, 200) 852 | 853 | z = line(x1=10, y1=20, x2=100, y2=200) 854 | """ 855 | if x1 is None and y1 is None and x2 is None and y2 is None: 856 | x1, y1 = -100, 0 857 | x2, y2 = 100, 0 858 | else: 859 | pairs = dict(x1=x1, y1=y1, x2=x2, y2=y2) 860 | missing = [name for name, value in pairs.items() if value is None] 861 | if missing: 862 | raise Exception("missing arguments for line: ", ", ".join(missing)) 863 | 864 | return Line(start=Point(x1, y1), end=Point(x2, y2), **kwargs) 865 | 866 | def point(x, y): 867 | """Creates a Point with x and y coordinates. 868 | """ 869 | return Point(x, y) 870 | 871 | def polygon(points, **kwargs): 872 | """Creates a polygon with given list points. 873 | 874 | Example: 875 | 876 | p1 = point(x=0, y=0) 877 | p2 = point(x=100, y=0) 878 | p3 = point(x=0, y=100) 879 | triangle = polygon([p1, p2, p3]) 880 | show(triangle) 881 | """ 882 | points_str = " ".join(f"{p.x},{p.y}" for p in points) 883 | return Shape(tag="polygon", points=points_str, **kwargs) 884 | 885 | def polyline(points, **kwargs): 886 | """Creates a polyline with given list points. 887 | 888 | Example: 889 | 890 | p1 = point(x=-50, y=50) 891 | p2 = point(x=0, y=-25) 892 | p3 = point(x=0, y=25) 893 | p4 = point(x=50, y=-50) 894 | line = polyline([p1, p2, p3, p4]) 895 | show(line) 896 | """ 897 | points_str = " ".join(f"{p.x},{p.y}" for p in points) 898 | return Shape(tag="polyline", points=points_str, **kwargs) 899 | 900 | def translate(x=0, y=0): 901 | """Translates a shape. 902 | 903 | Examples: 904 | 905 | Translate a shape by 10 units in x direction. 906 | 907 | shape = circle() | translate(x=10) 908 | 909 | Translate a shape by 10 units in y direction. 910 | 911 | shape = circle() | translate(y=10) 912 | 913 | Translate a shape by 10 units in x direction and 20 units in y direction. 914 | 915 | shape = circle() | translate(x=10, y=20) 916 | """ 917 | return Translate(x=x, y=y) 918 | 919 | def scale(s=None, x=1, y=1): 920 | """Scales a shape. 921 | 922 | Examples: 923 | 924 | Scale a shape in both x and y directions: 925 | 926 | shape = circle() | scale(0.5) 927 | 928 | Scale a shape in only in x direction: 929 | 930 | shape = circle() | scale(x=0.5) 931 | 932 | Scale a shape in only in y direction: 933 | 934 | shape = circle() | scale(y=0.5) 935 | 936 | Scale a shape differently in x and y directions: 937 | 938 | shape = circle() | scale(x=0.5, y=0.75) 939 | """ 940 | if s is not None: 941 | return Scale(sx=s, sy=s) 942 | else: 943 | return Scale(sx=x, sy=y) 944 | 945 | def rotate(angle): 946 | """Rotates a shape. 947 | 948 | Examples: 949 | 950 | Rotate a shape by 30 degrees 951 | 952 | shape = line() | rotate(30) 953 | """ 954 | return Rotate(angle) 955 | 956 | def repeat(n, transformation): 957 | """Repeats a transformation multiple times on a shape. 958 | 959 | Examples: 960 | 961 | Repeatly rotate a line 9 times by 10 degrees. 962 | 963 | shape = line() | repeat(9, rotate(10)) 964 | """ 965 | return Repeat(n, transformation) 966 | 967 | def combine(shapes): 968 | """The combine function combines a list of shapes into a single shape. 969 | 970 | Example: 971 | >>> shapes = [circle(r=50), circle(r=100), circle(r=150)] 972 | >>> shape = combine(shapes) 973 | >>> show(shape) 974 | """ 975 | return Group(shapes) 976 | 977 | def color(r, g, b, a=None): 978 | """Creates a color with given r g b values. 979 | 980 | Parameters: 981 | 982 | r - the red component of the color, allowed range is 0-255. 983 | g - the green component of the color, allowed range is 0-255. 984 | b - the blue component of the color, allowed range is 0-255. 985 | a - optional argument to indicate the transparency or the 986 | alpha value. The allowed range is 0-1. 987 | """ 988 | if a is None: 989 | return f"rgb({r}, {g}, {b})" 990 | else: 991 | return f"rgba({r}, {g}, {b}, {a})" 992 | 993 | def random(a=None, b=None): 994 | """Creates a random number. 995 | 996 | The random function can be used in three ways: 997 | 998 | random() # returns a random number between 0 and 1 999 | random(n) # returns a random number between 0 and n 1000 | random(n1, n2) # returns a random number between n1 and n2 1001 | 1002 | Examples: 1003 | 1004 | >>> random() 1005 | 0.4336206360591218 1006 | >>> random(10) 1007 | 1.436301598755494 1008 | >>> random(5, 10) 1009 | 7.471950621969087 1010 | """ 1011 | if a is None and b is None: 1012 | return random_module.random() 1013 | elif a is not None and b is None: 1014 | return a * random_module.random() 1015 | else: 1016 | delta = b - a 1017 | return a + delta * random_module.random() 1018 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Joy 2 | theme: 3 | name: material 4 | 5 | nav: 6 | - index.md 7 | - reference.md 8 | 9 | repo_url: https://github.com/fossunited/joy 10 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | exec pytest 4 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fossunited/joy/d8f062a057df3f3ef9445032e7e09674d0c418fc/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_basic_shapes.yml: -------------------------------------------------------------------------------- 1 | name: test circle 2 | code: | 3 | Circle() 4 | expected: | 5 | 6 | --- 7 | name: test circle with radius 8 | code: | 9 | Circle(radius=50) 10 | expected: | 11 | 12 | --- 13 | name: test circle with center and radius 14 | code: | 15 | Circle(center=Point(100, 200), radius=50) 16 | expected: | 17 | 18 | --- 19 | name: test circle with stroke and fill 20 | code: | 21 | Circle(center=Point(100, 200), radius=50, stroke="black", fill="green") 22 | expected: | 23 | 24 | --- 25 | name: test rectangle 26 | code: | 27 | Rectangle() 28 | expected: | 29 | 30 | --- 31 | name: test rectangle with width and height 32 | code: | 33 | Rectangle(width=200, height=200) 34 | expected: | 35 | 36 | --- 37 | name: test rectangle with center, width and height 38 | code: | 39 | Rectangle(center=Point(100, 100), width=200, height=100) 40 | expected: | 41 | 42 | --- 43 | name: test rect with stroke and fill 44 | code: | 45 | Rectangle(center=Point(100, 100), width=200, height=100, stroke="black", fill="green") 46 | expected: | 47 | 48 | 49 | --- 50 | name: test line 51 | code: | 52 | Line() 53 | expected: | 54 | 55 | --- 56 | name: test line with attrs 57 | code: | 58 | Line(start=Point(x=10, y=20), end=Point(x=30, y=40)) 59 | expected: | 60 | 61 | --- 62 | name: test line with stroke 63 | code: | 64 | Line(start=Point(x=10, y=20), end=Point(x=30, y=40), stroke="red") 65 | expected: | 66 | -------------------------------------------------------------------------------- /tests/test_combine.yml: -------------------------------------------------------------------------------- 1 | name: test comnbine 2 | code: | 3 | Circle() + Rectangle() 4 | expected: | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/test_ellipse.yml: -------------------------------------------------------------------------------- 1 | name: test ellipse 2 | code: | 3 | Ellipse() 4 | expected: | 5 | 6 | --- 7 | name: test ellipse with width and height 8 | code: | 9 | Ellipse(width=100, height=200) 10 | expected: | 11 | 12 | --- 13 | name: test ellipse with center, width and height 14 | code: | 15 | Ellipse(center=Point(x=50, y=50), width=100, height=200) 16 | expected: | 17 | 18 | --- 19 | name: test ellipse with stroke and fill 20 | code: | 21 | Ellipse(center=Point(x=50, y=50), width=100, height=200, stroke="black", fill="green") 22 | expected: | 23 | 24 | -------------------------------------------------------------------------------- /tests/test_joy.py: -------------------------------------------------------------------------------- 1 | """Unit tests for joy. 2 | 3 | To run tests: 4 | 5 | $ py.test . 6 | """ 7 | from joy import ( 8 | render_tag, 9 | Point, Shape, 10 | Translate, Rotate) 11 | import pytest 12 | import re 13 | import yaml 14 | from pathlib import Path 15 | 16 | @pytest.fixture() 17 | def reset_joy(): 18 | import joy 19 | # reset id_suffix and shape counter 20 | joy.ID_SUFFIX = "0000" 21 | joy.shape_seq = joy.shape_sequence() 22 | yield 23 | 24 | def test_clone(): 25 | node = Shape("circle", x=0, y=0, r=10) 26 | 27 | # attrs should not be shared after clone 28 | node2 = node.clone() 29 | node2.attrs['x'] = 20 30 | assert node.x == 0 31 | 32 | def test_render_tag(): 33 | assert render_tag("circle") == "" 34 | assert render_tag("circle", cx=0, cy=0, r=10) == '' 35 | assert render_tag("circle", cx=0, cy=0, r=10, close=True) == '' 36 | assert render_tag("circle", fill='text "with" quotes') == '' 37 | 38 | def test_rotate(): 39 | assert Rotate(angle=45).as_str() == "rotate(45)" 40 | assert Rotate(angle=45, anchor=Point(10, 20)).as_str() == "rotate(45 10 20)" 41 | 42 | def test_translate(): 43 | assert Translate(x=10, y=20).as_str() == "translate(10 20)" 44 | 45 | def read_tests_files(): 46 | tests = [] 47 | p = Path(__file__).parent 48 | files = p.rglob('*.yml') 49 | for f in files: 50 | items = list(yaml.safe_load_all(f.open())) 51 | items = [dict(item, name="{}: {}".format(f.name, item['name'])) for item in items] 52 | tests.extend(items) 53 | return tests 54 | 55 | # Get all tests 56 | testdata = read_tests_files() 57 | test_ids = [t['name'] for t in testdata] 58 | 59 | @pytest.mark.parametrize('testspec', testdata, ids=test_ids) 60 | def test_shapes(testspec, reset_joy): 61 | code = testspec['code'] 62 | expected = testspec['expected'] 63 | 64 | env = {} 65 | exec("from joy import *", env, env) 66 | node = eval(code, env) 67 | 68 | # svg = normalize_space(node._svg()) 69 | # expected = normalize_space(expected) 70 | svg = node._svg().strip() 71 | expected = expected.strip() 72 | 73 | assert expected == svg 74 | 75 | def normalize_space(text): 76 | return re.sub(r"\s+", " ", text).strip() 77 | -------------------------------------------------------------------------------- /tests/test_repeat.yml: -------------------------------------------------------------------------------- 1 | name: test repeat with rotate on line 2 | code: | 3 | Line() | Repeat(4, Rotate(angle=45)) 4 | expected: | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | --- 21 | name: test repeat with rotate|scale on line 22 | code: | 23 | line() | repeat(4, rotate(angle=45) | scale(x=0.9)) 24 | expected: | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/test_transforms.yml: -------------------------------------------------------------------------------- 1 | name: test translate 2 | code: | 3 | Circle(radius=50) | Translate(x=10, y=20) 4 | expected: | 5 | 6 | --- 7 | name: test rotate 8 | code: | 9 | Rectangle() | Rotate(angle=45) 10 | expected: | 11 | 12 | --- 13 | name: test rotate with anchor 14 | code: | 15 | Rectangle(width=200, height=200) | Rotate(angle=45, anchor=Point(10, 20)) 16 | expected: | 17 | 18 | --- 19 | name: test scale 20 | code: | 21 | Circle() | Scale(sx=1, sy=0.5) 22 | expected: | 23 | --------------------------------------------------------------------------------