├── .codeclimate.yml ├── .darglint ├── .dockerignore ├── .editorconfig ├── .flake8 ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── support-request.md ├── dependabot.yml └── workflows │ ├── coverage.yml │ ├── dependabot.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nojekyll ├── .pre-commit-config.yaml ├── .pydocstyle ├── .readthedocs.yml ├── CODE_OF_CONDUCT.rst ├── CONTRIBUTING.rst ├── Dockerfile ├── HISTORY.md ├── LICENSE ├── README.rst ├── codecov.yaml ├── conftest.py ├── docs ├── Makefile ├── conf.py ├── images │ ├── angular_dimension.png │ ├── arc.png │ ├── arc_with_text.png │ ├── arrow.png │ ├── arrow_with_text.png │ ├── circle.png │ ├── cubic_bezier.png │ ├── curve.png │ ├── dashpot.png │ ├── distance_with_text.png │ ├── double_arrow.png │ ├── figure.png │ ├── line.png │ ├── linear_dimension.png │ ├── moment.png │ ├── radial_dimension.png │ ├── rectangle.png │ ├── simple_support.png │ ├── sketchyfunc1.png │ ├── sketchyfunc2.png │ ├── sketchyfunc3.png │ ├── sketchyfunc4.png │ ├── spline.png │ ├── spring.png │ ├── text.png │ ├── triangle.png │ ├── uniform_load.png │ ├── velocity_profile.png │ ├── wall.png │ ├── wheel.png │ └── wheel_on_inclined_plane.png ├── index.rst ├── modules.rst └── tutorial │ ├── basic_drawing.rst │ ├── build_images.py │ ├── examples │ ├── basic_drawing_1.py │ ├── images │ │ └── basic_drawing_1.png │ └── utils │ │ ├── __init__.py │ │ └── change_extension.py │ ├── index.rst │ ├── introduction.rst │ └── pendulum.rst ├── examples ├── animation.py ├── beam1.py ├── beam2.py ├── colors_test.py ├── comprehensive_rectangles.py ├── finite_differences.py ├── flow_over_gaussian.py ├── hatch_test.py ├── hello_world.py ├── layered_medium_2.py ├── layered_medium_3xi.py ├── layered_medium_general.py ├── mesh_function.py ├── oscillator_sketch1.py ├── pendulum1.py ├── pendulum2.py ├── simple_axis.py ├── staggered_mesh_function.py ├── vehicle.py ├── vehicle_dim.py └── wheel_on_inclined_plane.py ├── fig ├── beam2_3.pdf ├── beam2_3.png ├── integral_comic_strip.png ├── integral_noncomic_strip.png ├── pendulum.mp4 └── pendulum2.png ├── noxfile.py ├── pyproject.toml ├── pysketcher ├── __init__.py ├── _angle.py ├── _arc.py ├── _arrow.py ├── _axis.py ├── _circle.py ├── _cubic_bezier_curve.py ├── _curve.py ├── _dashpot.py ├── _drawable.py ├── _figure.py ├── _force.py ├── _line.py ├── _moment.py ├── _point.py ├── _rectangle.py ├── _shape.py ├── _simple_support.py ├── _sketchy_func.py ├── _spline.py ├── _spring.py ├── _style.py ├── _text.py ├── _triangle.py ├── _uniform_load.py ├── _utils │ ├── __init__.py │ └── doc_enum.py ├── _velocity_profile.py ├── _wall.py ├── _warning.py ├── _wheel.py ├── annotation │ ├── __init__.py │ ├── _arc_annotation.py │ ├── _line_annotation.py │ └── _text_position.py ├── backend │ ├── __init__.py │ ├── backend.py │ └── matplotlib │ │ ├── __init__.py │ │ ├── _matplotlib_adapter.py │ │ ├── _matplotlib_backend.py │ │ ├── _matplotlib_composition.py │ │ ├── _matplotlib_curve.py │ │ ├── _matplotlib_style.py │ │ └── _matplotlib_text.py ├── composition │ ├── __init__.py │ └── _composition.py ├── dimension │ ├── __init__.py │ ├── _angular_dimension.py │ ├── _linear_dimension.py │ └── _radial_dimension.py ├── images │ └── .gitignore └── shapes.pyc └── tests ├── __init__.py ├── strategies ├── __init__.py └── _float.py ├── test_angle.py ├── test_compositions ├── __init__.py └── test_composition.py ├── test_line.py ├── test_point.py ├── test_shape.py ├── test_style.py └── utils ├── __init__.py ├── base_image.png ├── compare_image.py ├── exceptions.py ├── given_inferred.py ├── is_close.py ├── new_image.png └── type_strategy.py /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 7 6 | exclude_patterns: 7 | - "doc/" 8 | - "examples/" 9 | - "tests/" 10 | plugins: 11 | bandit: 12 | enabled: true 13 | Pep8: 14 | enabled: true 15 | Radon: 16 | enabled: true 17 | SonarPython: 18 | enabled: true 19 | -------------------------------------------------------------------------------- /.darglint: -------------------------------------------------------------------------------- 1 | [darglint] 2 | strictness = short 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/.dockerignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 79 14 | [*.py] 15 | max_line_length = 88 16 | 17 | # Use 2 spaces for the HTML files 18 | [*.html] 19 | indent_size = 2 20 | 21 | # The JSON files contain newlines inconsistently 22 | [*.json] 23 | indent_size = 2 24 | 25 | [docs/**.txt] 26 | max_line_length = 79 27 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = 3 | .git, 4 | .venv, 5 | .tox, 6 | .hypothesis, 7 | .github, 8 | .circlci, 9 | dist, 10 | pysketcher.egg-info, 11 | test-results, 12 | # unmigrated examples 13 | examples/ForwardEuler_comic_strip.py, 14 | examples/integral_comic_strip.py, 15 | examples/pendulum2.py 16 | max-line-length = 80 17 | select = B,B9,B950,BLK,C,D,DAR,E,F,I,S,W 18 | ignore = E203, E501, W503, D107 19 | application-import-names = pysketcher,tests 20 | import-order-style = google 21 | per-file-ignores = 22 | examples/*:S101,D100,D101,D102,D103,D104,D107 23 | docs/*:S101,D100,D101,D102,D103,D104,D107 24 | tests/*:S101,D100,D101,D102,D103,D104,D107 25 | tests/utils/*:S101,D100,D101,D102,D103,D104,D107 26 | noxfile.py:D100,D101,D102,D103 27 | pysketcher/backend/*:D100,D101,D102,D103,D104,D105 28 | pysketcher/_builder/*:D100,D101,D102,D103,D104,D105 29 | pysketcher/type_checking/*:D100,D101,D102,D103,D104,D105 30 | docstring-convention = google 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | * **What is the current behaviour?** 11 | 12 | 13 | * **Please provide the steps to reproduce and if possible a minimal demo of the problem** 14 | 15 | 16 | * **What is the expected behaviour?** 17 | 18 | 19 | * **What is the motivation / use case for changing the behaviour? That is, what does this bug prevent PySketcher users from doing?** 20 | 21 | 22 | * **Please tell us about your environment:** 23 | 24 | - Version: 0.0.10 25 | - OS: [all | Linux | MacOS | Windows ] 26 | - Python Version: [all | 3.8 | 3.9 | 3.10] 27 | 28 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, gitter, etc) 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | * **What is the current behaviour?** 11 | 12 | 13 | * **What is the new behaviour you are suggesting?** 14 | 15 | 16 | * **What is the motivation / use case for changing the behaviour? Does this let PySketcher be used for something which it can't be used for at the moment?** 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/support-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Request 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: support request 6 | assignees: '' 7 | 8 | --- 9 | 10 | * **Please describe the problem you need help with** 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | target-branch: master 9 | assignees: 10 | - rvodden 11 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/coverage.yml 2 | --- 3 | name: Coverage 4 | on: push 5 | jobs: 6 | coverage: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: FedericoCarboni/setup-ffmpeg@v1 11 | - uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.13' 14 | architecture: x64 15 | - run: pip install nox==2024.10.09 16 | - run: nox --sessions tests-3.13 examples-3.13 17 | env: 18 | CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} 19 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-approve 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1.1.1 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Approve a PR 19 | run: gh pr review --approve "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | - name: Enable auto-merge for Dependabot PRs 24 | run: gh pr merge --auto --rebase "$PR_URL" 25 | env: 26 | PR_URL: ${{github.event.pull_request.html_url}} 27 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/release.yml 2 | name: Release 3 | on: 4 | release: 5 | types: [published] 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | name: Build & Publish 10 | environment: release 11 | permissions: 12 | # IMPORTANT: this permission is mandatory for trusted publishing 13 | id-token: write 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Install Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: '3.13' 21 | architecture: x64 22 | - name: Install FFMpeg 23 | uses: FedericoCarboni/setup-ffmpeg@v1 24 | - name: Build 25 | run: | 26 | pip install ".[build]" 27 | nox -s build-3.13 28 | - name: Publish 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tests 3 | on: push 4 | jobs: 5 | tests: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | os-version: 10 | - "ubuntu-22.04" 11 | python-version: 12 | - "3.11" 13 | - "3.12" 14 | - "3.13" 15 | experimental: 16 | - false 17 | include: 18 | - python-version: "3.12" 19 | os-version: "windows-2019" 20 | experimental: true 21 | continue-on-error: ${{ matrix.experimental }} 22 | runs-on: ${{ matrix.os-version }} 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: FedericoCarboni/setup-ffmpeg@v1 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | architecture: x64 30 | - run: pip install nox==2024.10.09 31 | - run: nox -s tests-${{ matrix.python-version }} examples-${{ matrix.python-version }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | # temporary files: 3 | build/§ 4 | dist/ 5 | *.bak 6 | *.swp 7 | *~ 8 | .*~ 9 | *.old 10 | tmp* 11 | temp* 12 | .#* 13 | \#* 14 | .tox/ 15 | 16 | .pytest_cache 17 | .mypy_cache 18 | *.egg-info/ 19 | 20 | .hypothesis/ 21 | .coverage 22 | test-results/ 23 | 24 | 25 | ### Python ### 26 | # Byte-compiled / optimized / DLL files 27 | __pycache__/ 28 | *.py[cod] 29 | *$py.class 30 | 31 | # C extensions 32 | *.so 33 | 34 | # Distribution / packaging 35 | .Python 36 | build/ 37 | develop-eggs/ 38 | dist/ 39 | downloads/ 40 | eggs/ 41 | .eggs/ 42 | lib/ 43 | lib64/ 44 | parts/ 45 | sdist/ 46 | var/ 47 | wheels/ 48 | pip-wheel-metadata/ 49 | share/python-wheels/ 50 | *.egg-info/ 51 | .installed.cfg 52 | *.egg 53 | MANIFEST 54 | 55 | # PyInstaller 56 | # Usually these files are written by a python script from a template 57 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 58 | *.manifest 59 | *.spec 60 | 61 | # Installer logs 62 | pip-log.txt 63 | pip-delete-this-directory.txt 64 | 65 | # Unit test / coverage reports 66 | htmlcov/ 67 | .tox/ 68 | .nox/ 69 | .coverage 70 | .coverage.* 71 | .cache 72 | nosetests.xml 73 | coverage.xml 74 | *.cover 75 | *.py,cover 76 | .hypothesis/ 77 | .pytest_cache/ 78 | pytestdebug.log 79 | 80 | # Translations 81 | *.mo 82 | *.pot 83 | 84 | # Django stuff: 85 | *.log 86 | local_settings.py 87 | db.sqlite3 88 | db.sqlite3-journal 89 | 90 | # Flask stuff: 91 | instance/ 92 | .webassets-cache 93 | 94 | # Scrapy stuff: 95 | .scrapy 96 | 97 | # Sphinx documentation 98 | docs/_build/ 99 | doc/_build/ 100 | 101 | # PyBuilder 102 | target/ 103 | 104 | # Jupyter Notebook 105 | .ipynb_checkpoints 106 | 107 | # IPython 108 | profile_default/ 109 | ipython_config.py 110 | 111 | # pyenv 112 | .python-version 113 | 114 | # pipenv 115 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 116 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 117 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 118 | # install all needed dependencies. 119 | #Pipfile.lock 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .env 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | 140 | # Spyder project settings 141 | .spyderproject 142 | .spyproject 143 | 144 | # Rope project settings 145 | .ropeproject 146 | 147 | # mkdocs documentation 148 | /site 149 | 150 | # mypy 151 | .mypy_cache/ 152 | .dmypy.json 153 | dmypy.json 154 | 155 | # Pyre type checker 156 | .pyre/ 157 | 158 | # pytype static type analyzer 159 | .pytype/ 160 | 161 | # End of https://www.toptal.com/developers/gitignore/api/python 162 | /pysketcher/images/ 163 | /docs/images/ 164 | *.mp4 165 | !fig/*.mp4 166 | 167 | test-results.xml 168 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/.nojekyll -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | - repo: https://github.com/psf/black 10 | rev: 23.3.0 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/asottile/blacken-docs 14 | rev: 1.14.0 15 | hooks: 16 | - id: blacken-docs 17 | - repo: https://github.com/pre-commit/pygrep-hooks 18 | rev: v1.10.0 # Use the ref you want to point at 19 | hooks: 20 | - id: rst-backticks 21 | - id: rst-directive-colons 22 | - id: rst-inline-touching-normal 23 | - repo: https://github.com/pycqa/flake8 24 | rev: '6.0.0' # pick a git hash / tag to point to 25 | hooks: 26 | - id: flake8 27 | - repo: https://github.com/commitizen-tools/commitizen 28 | rev: 3.4.0 29 | hooks: 30 | - id: commitizen 31 | stages: [commit-msg] 32 | -------------------------------------------------------------------------------- /.pydocstyle: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | convention = google 3 | match-dir = ^(?!(\..*|examples|tests|docs)).* 4 | match = .*\.py 5 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | 11 | python: 12 | install: 13 | - method: pip 14 | path: . 15 | extra_requirements: 16 | - documentation 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | ************************************ 2 | Contributor Covenant Code of Conduct 3 | ************************************ 4 | 5 | Our Pledge 6 | ########## 7 | 8 | In the interest of fostering an open and welcoming environment, we as 9 | contributors and maintainers pledge to making participation in our project and 10 | our community a harassment-free experience for everyone, regardless of age, body 11 | size, disability, ethnicity, sex characteristics, gender identity and expression, 12 | level of experience, education, socio-economic status, nationality, personal 13 | appearance, race, religion, or sexual identity and orientation. 14 | 15 | Our Standards 16 | ############# 17 | 18 | Examples of behavior that contributes to creating a positive environment 19 | include: 20 | 21 | * Using welcoming and inclusive language 22 | * Being respectful of differing viewpoints and experiences 23 | * Gracefully accepting constructive criticism 24 | * Focusing on what is best for the community 25 | * Showing empathy towards other community members 26 | 27 | Examples of unacceptable behavior by participants include: 28 | 29 | * The use of sexualized language or imagery and unwelcome sexual attention or 30 | advances 31 | * Trolling, insulting/derogatory comments, and personal or political attacks 32 | * Public or private harassment 33 | * Publishing others' private information, such as a physical or electronic 34 | address, without explicit permission 35 | * Other conduct which could reasonably be considered inappropriate in a 36 | professional setting 37 | 38 | Our Responsibilities 39 | #################### 40 | 41 | Project maintainers are responsible for clarifying the standards of acceptable 42 | behavior and are expected to take appropriate and fair corrective action in 43 | response to any instances of unacceptable behavior. 44 | 45 | Project maintainers have the right and responsibility to remove, edit, or 46 | reject comments, commits, code, wiki edits, issues, and other contributions 47 | that are not aligned to this Code of Conduct, or to ban temporarily or 48 | permanently any contributor for other behaviors that they deem inappropriate, 49 | threatening, offensive, or harmful. 50 | 51 | Scope 52 | ##### 53 | 54 | This Code of Conduct applies both within project spaces and in public spaces 55 | when an individual is representing the project or its community. Examples of 56 | representing a project or community include using an official project e-mail 57 | address, posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. Representation of a project may be 59 | further defined and clarified by project maintainers. 60 | 61 | Enforcement 62 | ########### 63 | 64 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 65 | reported by contacting the project team at richard@vodden.com. All 66 | complaints will be reviewed and investigated and will result in a response that 67 | is deemed necessary and appropriate to the circumstances. The project team is 68 | obligated to maintain confidentiality with regard to the reporter of an incident. 69 | Further details of specific enforcement policies may be posted separately. 70 | 71 | Project maintainers who do not follow or enforce the Code of Conduct in good 72 | faith may face temporary or permanent repercussions as determined by other 73 | members of the project's leadership. 74 | 75 | Attribution 76 | ########### 77 | 78 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 79 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see 84 | https://www.contributor-covenant.org/faq 85 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/Dockerfile -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | 2 | # History 3 | 4 | ## 0.0.1 (2020–05–17) 5 | 6 | * First release on PyPI. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Richard Vodden 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.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | PySketcher 3 | ============ 4 | 5 | .. image:: https://github.com/rvodden/pysketcher/workflows/Tests/badge.svg 6 | :target: https://github.com/rvodden/pysketcher/actions?query=workflow%3ATests+branch%3Amaster 7 | 8 | .. image:: https://badgen.net/pypi/v/pysketcher?icon=pypi 9 | :target: https://pypi.org/project/pysketcher/ 10 | 11 | .. image:: https://api.codeclimate.com/v1/badges/eae2c2aa97080fbfed7e/maintainability 12 | :target: https://codeclimate.com/github/rvodden/pysketcher/maintainability 13 | 14 | .. image:: https://codecov.io/gh/rvodden/pysketcher/branch/master/graph/badge.svg?token=AHCKOL75VY 15 | :target: https://codecov.io/gh/rvodden/pysketcher 16 | 17 | .. image:: https://readthedocs.org/projects/pysketcher/badge/?version=latest&style=flat 18 | :target: https://pysketcher.readthedocs.io/en/latest/ 19 | 20 | .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white 21 | :target: https://github.com/pre-commit/pre-commit 22 | 23 | .. image:: https://img.shields.io/badge/hypothesis-tested-brightgreen.svg 24 | :target: https://hypothesis.readthedocs.io/ 25 | 26 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 27 | :target: https://github.com/psf/black 28 | 29 | .. image:: https://badgen.net/github/dependabot/rvodden/pysketcher?icon=github 30 | :target: https://github.com/rvodden/pysketcher 31 | 32 | **This is alpha software - the interface is likely to change with every release prior to 0.1.0.** 33 | 34 | PySketcher is a modern Python library designed to make creating geometric, mathematical and physical diagrams very 35 | easy. 36 | 37 | This library is continues the legacy of Hans Petter Langtangen. Work done since he sadly passed in 2016 includes: 38 | 39 | 1. The MatlibplotDraw object is no longer global and is no longer tightly coupled to the shape object. There is now a DrawingTool interface which this class implements. 40 | 41 | 2. Code is organised into multiple files and published on pypi. 42 | 43 | 3. Shapes are immutable. This means functions such as ``rotate`` return modified copies of the original shape, rather than altering the shape on which they are called. 44 | 45 | 4. Angles are in radians not degrees. 46 | 47 | 5. The Composition object is used more consistently. Previously objects such as Beam were direct children of Shape which led to code repetition. 48 | 49 | `Please see the documentation for more information `_. 50 | -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | status: 9 | project: 10 | default: 11 | target: auto 12 | threshold: 0% 13 | only_pulls: true 14 | patch: 15 | default: 16 | target: 80% 17 | 18 | parsers: 19 | gcov: 20 | branch_detection: 21 | conditional: yes 22 | loop: yes 23 | method: no 24 | macro: no 25 | 26 | comment: 27 | layout: "reach,diff,flags,files,footer" 28 | behavior: default 29 | require_changes: no 30 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from hypothesis.strategies import register_type_strategy 4 | 5 | import pysketcher as ps 6 | from pysketcher.backend.matplotlib import MatplotlibBackend 7 | 8 | from tests.strategies import make_float, make_angle 9 | 10 | 11 | @pytest.fixture(scope="session", autouse=True) 12 | def setup_testing(request): 13 | np.seterr(over="warn", divide="warn") 14 | register_type_strategy(ps.Angle, make_angle()) 15 | register_type_strategy(float, make_float()) 16 | 17 | 18 | @pytest.fixture(autouse=True) 19 | def add_np(doctest_namespace): 20 | doctest_namespace["np"] = np 21 | doctest_namespace["ps"] = ps 22 | doctest_namespace["MatplotlibBackend"] = MatplotlibBackend 23 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -b coverage 22 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("..")) 17 | 18 | import pysketcher # noqa: E402 19 | 20 | # The short X.Y version. 21 | version = ".".join(pysketcher.__version__.split(".", 2)[:2]) 22 | # The full version, including alpha/beta/rc tags. 23 | release = pysketcher.__version__ 24 | 25 | 26 | # -- Project information ----------------------------------------------------- 27 | 28 | project = "PySketcher" 29 | copyright = "2020, Richard Vodden" 30 | author = "Richard Vodden" 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 36 | # ones. 37 | extensions = [ 38 | "sphinx.ext.napoleon", 39 | "sphinx.ext.autodoc", 40 | "sphinx.ext.coverage", 41 | "sphinx.ext.autosectionlabel", 42 | "sphinx.ext.linkcode", 43 | "sphinx.ext.imgmath", 44 | # "sphinx_autodoc_typehints", 45 | ] 46 | 47 | # Add any paths that contain templates here, relative to this directory. 48 | templates_path = ["_templates"] 49 | 50 | master_doc = "index" 51 | 52 | source_suffix = ".rst" 53 | 54 | # List of patterns, relative to source directory, that match files and 55 | # directories to ignore when looking for source files. 56 | # This pattern also affects html_static_path and html_extra_path. 57 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 58 | 59 | pygments_style = "sphinx" 60 | 61 | highlight_language = "python" 62 | 63 | # AutoDoc Settings 64 | 65 | autodoc_typehints = "description" 66 | autoclass_content = "class" 67 | autodoc_member_order = "groupwise" 68 | 69 | 70 | def linkcode_resolve(domain, info): 71 | def find_source(): 72 | # try to find the file and line number, based on code from numpy: 73 | # https://github.com/numpy/numpy/blob/master/doc/source/conf.py#L286 74 | obj = sys.modules[info["module"]] 75 | for part in info["fullname"].split("."): 76 | obj = getattr(obj, part) 77 | import inspect 78 | import os 79 | 80 | fn = inspect.getsourcefile(obj) 81 | fn = os.path.relpath(fn, start=os.path.dirname(pysketcher.__file__)) 82 | source, lineno = inspect.getsourcelines(obj) 83 | return fn, lineno, lineno + len(source) - 1 84 | 85 | if domain != "py" or not info["module"]: 86 | return None 87 | try: 88 | filename = "pysketcher/%s#L%d-L%d" % find_source() 89 | except Exception: 90 | filename = info["module"].replace(".", "/") + ".py" 91 | return "https://github.com/rvodden/pysketcher/blob/%s/%s" % (release, filename) 92 | 93 | 94 | # -- Options for HTML output ------------------------------------------------- 95 | 96 | # The theme to use for HTML and HTML Help pages. See the documentation for 97 | # a list of builtin themes. 98 | # 99 | html_theme = "furo" 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | html_static_path = ["tutorial/examples/images"] 105 | -------------------------------------------------------------------------------- /docs/images/angular_dimension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/angular_dimension.png -------------------------------------------------------------------------------- /docs/images/arc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/arc.png -------------------------------------------------------------------------------- /docs/images/arc_with_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/arc_with_text.png -------------------------------------------------------------------------------- /docs/images/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/arrow.png -------------------------------------------------------------------------------- /docs/images/arrow_with_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/arrow_with_text.png -------------------------------------------------------------------------------- /docs/images/circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/circle.png -------------------------------------------------------------------------------- /docs/images/cubic_bezier.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/cubic_bezier.png -------------------------------------------------------------------------------- /docs/images/curve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/curve.png -------------------------------------------------------------------------------- /docs/images/dashpot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/dashpot.png -------------------------------------------------------------------------------- /docs/images/distance_with_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/distance_with_text.png -------------------------------------------------------------------------------- /docs/images/double_arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/double_arrow.png -------------------------------------------------------------------------------- /docs/images/figure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/figure.png -------------------------------------------------------------------------------- /docs/images/line.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/line.png -------------------------------------------------------------------------------- /docs/images/linear_dimension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/linear_dimension.png -------------------------------------------------------------------------------- /docs/images/moment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/moment.png -------------------------------------------------------------------------------- /docs/images/radial_dimension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/radial_dimension.png -------------------------------------------------------------------------------- /docs/images/rectangle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/rectangle.png -------------------------------------------------------------------------------- /docs/images/simple_support.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/simple_support.png -------------------------------------------------------------------------------- /docs/images/sketchyfunc1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/sketchyfunc1.png -------------------------------------------------------------------------------- /docs/images/sketchyfunc2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/sketchyfunc2.png -------------------------------------------------------------------------------- /docs/images/sketchyfunc3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/sketchyfunc3.png -------------------------------------------------------------------------------- /docs/images/sketchyfunc4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/sketchyfunc4.png -------------------------------------------------------------------------------- /docs/images/spline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/spline.png -------------------------------------------------------------------------------- /docs/images/spring.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/spring.png -------------------------------------------------------------------------------- /docs/images/text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/text.png -------------------------------------------------------------------------------- /docs/images/triangle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/triangle.png -------------------------------------------------------------------------------- /docs/images/uniform_load.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/uniform_load.png -------------------------------------------------------------------------------- /docs/images/velocity_profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/velocity_profile.png -------------------------------------------------------------------------------- /docs/images/wall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/wall.png -------------------------------------------------------------------------------- /docs/images/wheel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/wheel.png -------------------------------------------------------------------------------- /docs/images/wheel_on_inclined_plane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/images/wheel_on_inclined_plane.png -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | =============================================== 2 | PySketcher - Precision Drawing Tool for Python 3 | =============================================== 4 | **This is alpha software - the interface is likely to change with every release prior to 0.1.0.** 5 | 6 | Tool for creating sketches of physical and mathematical problems in terms of Python code. 7 | 8 | This library is very heavily based on the thinking of Hans Petter Langtangen however 9 | very little if any of his code remains. Significant deviations from his library are: 10 | 11 | 1. The MatlibplotDraw object is no longer global and is no longer tightly coupled to the shape object. There is now a DrawingTool interface which this class implements. 12 | 13 | 2. Code is organised into multiple files and published on pypi. 14 | 15 | 3. Shapes are immutable. This means functions such as ``rotate`` return modified copies of the original shape, rather than altering the shape on which they are called. 16 | 17 | 4. Angles are in radians not degrees. 18 | 19 | 5. The Composition object is used more consistently. Previously objects such as Beam where direct children of Shape which led to code repetition. 20 | 21 | Contents 22 | ======== 23 | 24 | .. toctree:: 25 | :maxdepth: 1 26 | 27 | Tutorial 28 | API Documentation 29 | 30 | Purpose 31 | ======= 32 | 33 | PySketcher can typically be used to draw figures like: 34 | 35 | .. image:: /images/wheel_on_inclined_plane.png 36 | 37 | Such figures can easily be *interactively* made using a lot of drawing 38 | programs. A Pysketcher figure, however, is defined trough 39 | computer code. This gives a great advantage: geometric features can be 40 | parameterized in terms of variables. Geometric variations are then 41 | trivially generated. Also, complicated figures can be built as a 42 | hierarchy of simpler elements. The figure can easily be made to move 43 | according to, e.g., a solution of a differential equation. 44 | 45 | Here is a very simple figure that illustrates how geometric features are 46 | parameterized by variables (H, R, L, etc.): 47 | 48 | .. image:: docs/src/tut/fig-tut/vehicle0_dim.png 49 | 50 | One can then quickly change parameters, below to 51 | ``R=0.5; L=5; H=2`` and ``R=2; L=7; H=1``, and get new figures that would be 52 | tedious to draw manually in an interactive tool. 53 | 54 | .. image:: docs/src/tut/fig-tut/vehicle_v23.png 55 | 56 | Another major feature of Pysketcher is the ability to let the 57 | sketch be dynamic and make an animation of the time evolution. 58 | Here is an example of a very simple vehicle on a bumpy road, 59 | where the solution of a differential equation (upper blue line) is fed 60 | back to the sketch to make a vertical displacement of the spring and 61 | other objects in the vehicle. `View animation`_] (the animation was created by 62 | `this Pysketcher script`_. 63 | 64 | .. _`View animation`: http://hplgit.github.io/bumpy/doc/src/mov-bumpy/m2_k1_5_b0_2/index.html 65 | .. _`this Pysketcher script`: https://github.com/hplgit/bumpy/blob/master/doc/src/fig-bumpy/bumpy_road_fig.py 66 | 67 | 68 | .. image:: http://hplgit.github.io/bumpy/doc/src/mov-bumpy/m2_k1_5_b0_2/tmp_frame_0030.png 69 | 70 | 71 | Examples 72 | ======== 73 | 74 | See the ``examples`` directory for some examples beyond the more basic 75 | ones in the tutorial. 76 | For example, an elastic beam can be sketched as 77 | 78 | 79 | ![](fig/beam2_3.png) 80 | 81 | The sketch was created by the program [`examples/beam2.py`](https://github.com/hplgit/pysketcher/tree/master/examples/beam2.py). 82 | 83 | Here is an illustration of the idea of numerical integration: 84 | 85 | 86 | ![](fig/integral_noncomic_strip.png) 87 | 88 | As shown in the figure-generating program [`examples/integral_comic_strip.py`](https://github.com/hplgit/pysketcher/tree/master/examples/integral_comic_strip.py), 89 | this illustration can easily be turned into an [XKCD](http://xkcd.com) type of comic strip: 90 | 91 | 92 | ![](fig/integral_comic_strip.png) 93 | 94 | 95 | History 96 | ======= 97 | 98 | Pysketcher was first constructed as a powerful educational example on 99 | object-oriented programming for the book 100 | *A Primer on Scientific Programming With Python*, but the tool quickly 101 | became so useful for the author that it was further developed and 102 | heavily used for creating figures in other documents. It has now been picked up by Richard 103 | Vodden, made a touch more pythonic, and compatible with later Python versions. 104 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | API Documentation 2 | ================= 3 | 4 | .. automodule:: pysketcher 5 | :members: 6 | :show-inheritance: 7 | -------------------------------------------------------------------------------- /docs/tutorial/basic_drawing.rst: -------------------------------------------------------------------------------- 1 | ############# 2 | Basic Drawing 3 | ############# 4 | 5 | Let's start by looking at a very simple ``PySketcher`` sketch which draws a single line: 6 | 7 | .. literalinclude:: examples/basic_drawing_1.py 8 | :end-before: # End Here 9 | :language: python 10 | 11 | This draws the image shown here: 12 | 13 | .. figure:: /tutorial/examples/images/basic_drawing_1.png 14 | -------------------------------------------------------------------------------- /docs/tutorial/build_images.py: -------------------------------------------------------------------------------- 1 | import os 2 | from subprocess import Popen # noqa: S404 3 | 4 | EXAMPLE_DIR = "examples" 5 | 6 | 7 | def main(): 8 | files = [ 9 | f 10 | for f in os.listdir(EXAMPLE_DIR) 11 | if os.path.isfile(os.path.join(EXAMPLE_DIR, f)) 12 | ] 13 | 14 | def has_py_extension(filename: str) -> bool: 15 | _, ext = os.path.splitext(filename) 16 | return ext == ".py" 17 | 18 | examples = [f for f in files if has_py_extension(f)] 19 | os.chdir(EXAMPLE_DIR) 20 | for example in examples: 21 | proc = Popen(["python", example]) # noqa: S603,S607 22 | proc.wait() 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /docs/tutorial/examples/basic_drawing_1.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | figure = ps.Figure(0.0, 5.0, 0.0, 5.0, MatplotlibBackend) 5 | 6 | a = ps.Point(1.0, 3.0) 7 | b = ps.Point(4.0, 3.0) 8 | 9 | line = ps.Line(a, b) 10 | figure.add(line) 11 | figure.show() 12 | 13 | 14 | # End Here 15 | import os # noqa: E402, I100 16 | 17 | from .utils.change_extension import change_extension # noqa: E402 18 | 19 | filename = change_extension(__file__, "png") 20 | figure.save(os.path.join("images", filename)) 21 | -------------------------------------------------------------------------------- /docs/tutorial/examples/images/basic_drawing_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/docs/tutorial/examples/images/basic_drawing_1.png -------------------------------------------------------------------------------- /docs/tutorial/examples/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .change_extension import change_extension 2 | 3 | __all__ = [change_extension] 4 | -------------------------------------------------------------------------------- /docs/tutorial/examples/utils/change_extension.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def change_extension(filename: str, extension: str) -> str: 5 | name, path = os.path.splitext(filename) 6 | return f"{name}.{extension}" 7 | -------------------------------------------------------------------------------- /docs/tutorial/index.rst: -------------------------------------------------------------------------------- 1 | ######## 2 | Tutorial 3 | ######## 4 | 5 | .. toctree:: 6 | 7 | Introduction 8 | Basic Drawing 9 | A Simple Pendulum 10 | -------------------------------------------------------------------------------- /docs/tutorial/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ------------ 3 | 4 | Formulation of physical problems makes heavy use of principal sketches such as the one in Figure 1. This particular sketch illustrates the classical mechanics problem of a rolling wheel on an inclined plane. The figure is made up many individual elements: a rectangle filled with a pattern (the inclined plane), a hollow circle with color (the wheel), arrows with labels (the N and Mg forces, and the x axis), an angle with symbol θ, and a dashed line indicating the starting location of the wheel. 5 | 6 | Drawing software and plotting programs can produce such figures quite easily in principle, but the amount of details the user needs to control with the mouse can be substantial. Software more tailored to producing sketches of this type would work with more convenient abstractions, such as circle, wall, angle, force arrow, axis, and so forth. And as soon we start programming to construct the figure we get a range of other powerful tools at disposal. For example, we can easily translate and rotate parts of the figure and make an animation that illustrates the physics of the problem. Programming as a superior alternative to interactive drawing is the mantra of this section. 7 | 8 | .. figure:: /images/wheel_on_inclined_plane.png 9 | 10 | A wheel on an inclined plane. 11 | -------------------------------------------------------------------------------- /docs/tutorial/pendulum.rst: -------------------------------------------------------------------------------- 1 | A Simple Pendulum 2 | ----------------- 3 | -------------------------------------------------------------------------------- /examples/animation.py: -------------------------------------------------------------------------------- 1 | """A very simple animation.""" 2 | import numpy as np 3 | 4 | from pysketcher import Angle, Circle, Figure, Line, Point 5 | from pysketcher.backend.matplotlib import MatplotlibBackend 6 | from pysketcher.composition import Composition 7 | 8 | 9 | def main(): 10 | circle = Circle(Point(0, 0), 1) 11 | line = Line(Point(0, 0), Point(0, 1)) 12 | 13 | def func(frame: int): 14 | new_line = line.rotate(Angle(2 * np.pi * frame / 360), Point(0, 0)) 15 | model = Composition({"circle": circle, "line": new_line}) 16 | return model 17 | 18 | fig = Figure(-1.2, 1.2, -1.2, 1.2, backend=MatplotlibBackend) 19 | fig.animate(func, (0, 360)) 20 | fig.save_animation("animation.mp4") 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /examples/beam1.py: -------------------------------------------------------------------------------- 1 | """A very simple beam.""" 2 | import logging 3 | 4 | # TODO: switch this to ps style importing 5 | from pysketcher import ( 6 | Figure, 7 | Force, 8 | LinearDimension, 9 | Point, 10 | Rectangle, 11 | SimpleSupport, 12 | Style, 13 | ) 14 | from pysketcher.backend.matplotlib import MatplotlibBackend 15 | from pysketcher.composition import Composition 16 | 17 | 18 | def main() -> None: 19 | logging.basicConfig(level=logging.INFO) 20 | 21 | L = 8.0 22 | H = 1.0 23 | x_pos = 2.0 24 | y_pos = 3.0 25 | 26 | fig = Figure(0, x_pos + 1.2 * L, 0, y_pos + 5 * H, MatplotlibBackend) 27 | 28 | p0 = Point(x_pos, y_pos) 29 | main = Rectangle(p0, L, H).set_fill_pattern(Style.FillPattern.UP_LEFT_TO_RIGHT) 30 | h = L / 16 # size of support, clamped wall etc 31 | support = SimpleSupport(p0, h) 32 | clamped = Rectangle(p0 + Point(L, 0) - Point(0, 2 * h), h, 6 * h).set_fill_pattern( 33 | Style.FillPattern.UP_RIGHT_TO_LEFT 34 | ) 35 | F_pt = Point(p0.x + L / 2, p0.y + H) 36 | force = Force("$F$", F_pt + Point(0, 2 * H), F_pt).set_line_width(3) 37 | L_dim = LinearDimension( 38 | "$L$", Point(x_pos, p0.y - 3 * h), Point(x_pos + L, p0.y - 3 * h) 39 | ) 40 | beam = Composition( 41 | { 42 | "main": main, 43 | "simply supported end": support, 44 | "clamped end": clamped, 45 | "force": force, 46 | "L": L_dim, 47 | } 48 | ) 49 | 50 | fig.add(beam) 51 | fig.show() 52 | 53 | 54 | if __name__ == "__main__": 55 | main() 56 | -------------------------------------------------------------------------------- /examples/beam2.py: -------------------------------------------------------------------------------- 1 | """A more sophisticated beam than in beam1.py.""" 2 | 3 | import logging 4 | 5 | import numpy as np 6 | 7 | from pysketcher import ( 8 | Curve, 9 | Figure, 10 | Force, 11 | LinearDimension, 12 | Moment, 13 | Point, 14 | Rectangle, 15 | SimpleSupport, 16 | Style, 17 | Text, 18 | UniformLoad, 19 | ) 20 | from pysketcher.backend.matplotlib import MatplotlibBackend 21 | from pysketcher.composition import Composition 22 | 23 | logging.basicConfig(level=logging.INFO) 24 | 25 | 26 | def main() -> None: 27 | L = 8.0 28 | a = 3 * L / 4 29 | b = L - a 30 | H = 1.0 31 | A = Point(0.0, 3.0) 32 | 33 | fig = Figure(-3, A.x + 1.5 * L, 0, A.y + 5 * H, MatplotlibBackend) 34 | 35 | beam = ( 36 | Rectangle(A, L, H) 37 | .set_fill_pattern(Style.FillPattern.UP_RIGHT_TO_LEFT) 38 | .set_line_color(Style.Color.BLUE) 39 | ) 40 | 41 | h = L / 16 # size of support, clamped wall etc 42 | 43 | clamped = Rectangle(A - Point(h, 0) - Point(0, 2 * h), h, 6 * h).set_fill_pattern( 44 | Style.FillPattern.UP_LEFT_TO_RIGHT 45 | ) 46 | 47 | load = UniformLoad(A + Point(0, H), L, H) 48 | load.set_line_width(1).set_line_color(Style.Color.BLACK) 49 | load_text = Text("$w$", load.mid_top + Point(0, h / 2.0)) 50 | 51 | B = A + Point(a, 0) 52 | C = B + Point(b, 0) 53 | 54 | support = SimpleSupport(B, h) # pt B is simply supported 55 | 56 | R1 = Force( 57 | "$R_1$", 58 | A - Point(0, 2 * H), 59 | A, 60 | # start_spacing=0.3 61 | ) 62 | R1.set_line_width(3).set_line_color(Style.Color.BLACK) 63 | R2 = Force( 64 | "$R2$", 65 | B - Point(0, 2 * H), 66 | support.mid_support, 67 | ) 68 | R2.set_line_width(3).set_line_color(Style.Color.BLACK) 69 | M1 = Moment( 70 | "$M_1$", 71 | center=A + Point(-H, H / 2), 72 | radius=H / 2, 73 | ) 74 | M1.line_color = "black" 75 | 76 | ab_level = Point(0, 3 * h) 77 | a_dim = LinearDimension("$a$", A - ab_level, B - ab_level) 78 | b_dim = LinearDimension("$b$", B - ab_level, C - ab_level) 79 | dims = Composition({"a": a_dim, "b": b_dim}) 80 | symbols = Composition( 81 | { 82 | "R1": R1, 83 | "R2": R2, 84 | "M1": M1, 85 | "w": load, 86 | "w text": load_text, 87 | "A": Text("$A$", A + Point(0.7 * h, -0.9 * h)), 88 | "B": Text("$B$", support.mid_support - Point(1.25 * h, 0)), 89 | "C": Text("$C$", C + Point(h / 2, -h / 2)), 90 | } 91 | ) 92 | 93 | annotations = Composition({"dims": dims, "symbols": symbols}) 94 | beam = Composition( 95 | {"beam": beam, "support": support, "clamped end": clamped, "load": load} 96 | ) 97 | 98 | def deflection(x, a, b, w) -> float: 99 | R1 = 5.0 / 8 * w * a - 3 * w * b**2 / (4 * a) 100 | R2 = 3.0 / 8 * w * a + w * b + 3 * w * b**2 / (4 * a) 101 | M1 = R1 * a / 3 - w * a**2 / 12 102 | y = ( 103 | -(M1 / 2.0) * x**2 104 | + 1.0 / 6 * R1 * x**3 105 | - w / 24.0 * x**4 106 | + 1.0 / 6 * R2 * np.where(x > a, 1, 0) * (x - a) ** 3 107 | ) 108 | return y 109 | 110 | xs = np.linspace(0, L, 101) 111 | ys = deflection(xs, a, b, w=1.0) 112 | ys /= abs(ys.max() - ys.min()) 113 | ys += A.y + H / 2 114 | points = Point.from_coordinate_lists(xs, ys) 115 | 116 | elastic_line = ( 117 | Curve(points) 118 | .set_line_color(Style.Color.RED) 119 | .set_line_style(Style.LineStyle.DASHED) 120 | .set_line_width(3) 121 | ) 122 | 123 | fig.add(beam) 124 | fig.add(annotations) 125 | fig.add(elastic_line) 126 | fig.show() 127 | 128 | 129 | if __name__ == "__main__": 130 | main() 131 | -------------------------------------------------------------------------------- /examples/colors_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pysketcher as ps 4 | from pysketcher.backend.matplotlib import MatplotlibBackend 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | 9 | def main() -> None: 10 | i = 1 11 | shapes = {} 12 | for line_color in ps.Style.Color: 13 | j = 1 14 | for fill_color in ps.Style.Color: 15 | logging.info("Line Color: %s", line_color) 16 | name: str = "Rectangle.%d.%d" % (i, j) 17 | rectangle = ps.Rectangle(ps.Point(i, j), 1, 1) 18 | rectangle.style.line_width = 3.0 19 | rectangle.style.line_color = line_color 20 | rectangle.style.fill_color = fill_color 21 | shapes[name] = rectangle 22 | j = j + 1 23 | i = i + 1 24 | 25 | model = ps.Composition(shapes) 26 | 27 | fig = ps.Figure(0, 12, 0, 12, backend=MatplotlibBackend) 28 | fig.add(model) 29 | fig.show() 30 | 31 | 32 | if __name__ == "__main__": 33 | main() 34 | -------------------------------------------------------------------------------- /examples/comprehensive_rectangles.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | from pysketcher.dimension import LinearDimension 4 | 5 | 6 | def main() -> None: 7 | rect = ps.Rectangle(ps.Point(1, 1), 4, 6) 8 | 9 | dim_w = LinearDimension(r"$w$", rect.lower_left, rect.lower_right) 10 | dim_h = LinearDimension(r"$h$", rect.lower_right, rect.upper_right) 11 | 12 | fig = ps.Figure(0, 8, 0, 8, backend=MatplotlibBackend) 13 | fig.add(rect) 14 | fig.add(dim_w) 15 | fig.add(dim_h) 16 | fig.show() 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /examples/flow_over_gaussian.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | import pysketcher as ps 6 | from pysketcher.backend.matplotlib import MatplotlibBackend 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | 10 | W = 5 # upstream area 11 | L = 10 # downstream area 12 | H = 4 # height 13 | sigma = 2 14 | alpha = 2 15 | 16 | 17 | # Create bottom 18 | def gaussian(x: float) -> float: 19 | return alpha * np.exp(-((x - W) ** 2) / (0.5 * sigma**2)) 20 | 21 | 22 | def velocity_profile(y: float) -> ps.Point: 23 | return ps.Point(2 * y * (2 * H - y) / H**2, 0) 24 | 25 | 26 | def main() -> None: 27 | wall = ps.Wall( 28 | [ps.Point(x, gaussian(x)) for x in np.linspace(W + L, 0, 51, endpoint=False)], 29 | 0.3, 30 | ) 31 | wall.style.line_color = ps.Style.Color.BROWN 32 | 33 | inlet_profile = ps.VelocityProfile(ps.Point(0, 0), H, velocity_profile, 5) 34 | inlet_profile.style.line_color = ps.Style.Color.BLUE 35 | 36 | symmetry_line = ps.Line(ps.Point(0, H), ps.Point(W + L, H)) 37 | symmetry_line.style.line_style = ps.Style.LineStyle.DASHED 38 | 39 | outlet = ps.Line(ps.Point(W + L, 0), ps.Point(W + L, H)) 40 | outlet.style.line_style = ps.Style.LineStyle.DASHED 41 | 42 | model = ps.Composition( 43 | { 44 | "bottom": wall, 45 | "inlet": inlet_profile, 46 | "symmetry line": symmetry_line, 47 | "outlet": outlet, 48 | } 49 | ) 50 | 51 | velocity = velocity_profile(H / 2.0) 52 | line = ps.Line(ps.Point(W - 2.5 * sigma, 0), ps.Point(W + 2.5 * sigma, 0)) 53 | line.style.line_style = ps.Style.LineStyle.DASHED 54 | symbols = { 55 | "alpha": ps.LinearDimension(r"$\alpha$", ps.Point(W, 0), ps.Point(W, alpha)), 56 | "W": ps.LinearDimension(r"$W$", ps.Point(0, -0.5), ps.Point(W, -0.5)), 57 | "L": ps.LinearDimension(r"$L$", ps.Point(W, -0.5), ps.Point(W + L, -0.5)), 58 | "v(y)": ps.Text("$v(y)$ ", ps.Point(H / 2.0, velocity.x)), 59 | "dashed line": line, 60 | } 61 | symbols = ps.Composition(symbols) 62 | 63 | fig = ps.Figure(0, W + L + 1, -2, H + 1, backend=MatplotlibBackend) 64 | fig.add(model) 65 | fig.add(symbols) 66 | fig.show() 67 | 68 | 69 | if __name__ == "__main__": 70 | main() 71 | -------------------------------------------------------------------------------- /examples/hatch_test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pysketcher as ps 4 | from pysketcher.backend.matplotlib import MatplotlibBackend 5 | 6 | 7 | def main() -> None: 8 | i = 1 9 | shapes_dict = {} 10 | for fill_pattern in ps.Style.FillPattern: 11 | logging.info("Fill Pattern: %s", fill_pattern) 12 | name: str = "Rectangle.%d" % i 13 | rectangle = ps.Rectangle(ps.Point(i, 1), 1, 1).set_fill_pattern(fill_pattern) 14 | shapes_dict[name] = rectangle 15 | i = i + 1.5 16 | 17 | shapes = ps.Composition(shapes_dict) 18 | 19 | fig = ps.Figure(0.0, 20.0, 0.0, 3.0, backend=MatplotlibBackend) 20 | fig.add(shapes) 21 | fig.show() 22 | 23 | 24 | if __name__ == "__main__": 25 | main() 26 | -------------------------------------------------------------------------------- /examples/hello_world.py: -------------------------------------------------------------------------------- 1 | """Minimialistic pysketcher example.""" 2 | import pysketcher as ps 3 | from pysketcher.backend.matplotlib import MatplotlibBackend 4 | 5 | 6 | def main() -> None: 7 | code = ps.Text("print 'Hello, World!'", ps.Point(2.5, 1.5)) 8 | 9 | code.style.fontsize = 24 10 | code.style.font_family = ps.TextStyle.FontFamily.MONO 11 | code.style.fill_color = ps.TextStyle.Color.GREY 12 | 13 | fig = ps.Figure(0.0, 5.0, 0.0, 3.0, backend=MatplotlibBackend) 14 | fig.add(code) 15 | fig.show() 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /examples/layered_medium_2.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | W = 10.0 5 | H = 10.0 6 | a = [0, 5, 10] 7 | 8 | 9 | def main() -> None: 10 | layers = { 11 | "layer%d" % i: ps.Line(ps.Point(0, a[i]), ps.Point(W, a[i])) 12 | for i in range(len(a)) 13 | } 14 | symbols_q = { 15 | "Omega_k%d" 16 | % i: ps.Text( 17 | r"$\Omega_%d$: $k_%d$" % (i, i), ps.Point(W / 2, 0.5 * (a[i] + a[i + 1])) 18 | ) 19 | for i in range(len(a) - 1) 20 | } 21 | 22 | sides = { 23 | "left": ps.Line(ps.Point(0, 0), ps.Point(0, H)), 24 | "right": ps.Line(ps.Point(W, 0), ps.Point(W, H)), 25 | } 26 | d = sides.copy() 27 | d.update(layers) 28 | d.update(symbols_q) 29 | model = ps.Composition(d) 30 | 31 | fig = ps.Figure(-1, W + 1, 1, H + 1, backend=MatplotlibBackend) 32 | fig.add(model) 33 | fig.show() 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /examples/layered_medium_3xi.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | W = 10.0 5 | H = 5.0 6 | a = [0, 3.5, 5] 7 | 8 | 9 | def main() -> None: 10 | fig = ps.Figure(-1, W + 1, -1, H + 1, backend=MatplotlibBackend) 11 | 12 | layers = { 13 | "layer%d" % i: ps.Line(ps.Point(0, a[i]), ps.Point(W, a[i])) 14 | for i in range(len(a)) 15 | } 16 | symbols_q = { 17 | "xi_%d" % i: ps.Text(r"$\xi_%d$" % i, ps.Point(W / 2, 0.5 * (a[i] + a[i + 1]))) 18 | for i in range(len(a) - 1) 19 | } 20 | symbols_q["xi_2"] = ps.Text(r"$\xi_2$", ps.Point(-0.5, a[1])) 21 | 22 | sides = { 23 | "left": ps.Line(ps.Point(0, 0), ps.Point(0, H)), 24 | "right": ps.Line(ps.Point(W, 0), ps.Point(W, H)), 25 | } 26 | d = sides.copy() 27 | d.update(layers) 28 | d.update(symbols_q) 29 | model = ps.Composition(d) 30 | 31 | fig.add(model) 32 | fig.show() 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /examples/layered_medium_general.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | W = 10.0 5 | H = 10.0 6 | 7 | a = [0, 1.5, 3, 4.5, 6, 8.2, 10] 8 | 9 | 10 | def main(): 11 | layers = { 12 | "layer%d" % i: ps.Line(ps.Point(0, a[i]), ps.Point(W, a[i])) 13 | for i in range(len(a)) 14 | } 15 | 16 | symbols_ell = { 17 | "l_%d" % i: ps.Text(r"$\ell_%d$" % i, ps.Point(-0.5, a[i])) 18 | for i in range(1, len(a) - 1) 19 | } 20 | 21 | for text in symbols_ell.values(): 22 | text.style.font_size = 24 23 | 24 | symbols_a = { 25 | "a_%d" % i: ps.Text("$a_%d$" % i, ps.Point(W / 2, 0.5 * (a[i] + a[i + 1]))) 26 | for i in range(len(a) - 1) 27 | } 28 | 29 | for text in symbols_a.values(): 30 | text.style.font_size = 24 31 | 32 | sides = { 33 | "left": ps.Line(ps.Point(0, 0), ps.Point(0, H)), 34 | "right": ps.Line(ps.Point(W, 0), ps.Point(W, H)), 35 | } 36 | d = sides.copy() 37 | d.update(layers) 38 | d.update(symbols_ell) 39 | d.update(symbols_a) 40 | model = ps.Composition(d) 41 | 42 | fig = ps.Figure(-1, W + 1, -1, H + 1, backend=MatplotlibBackend) 43 | fig.add(model) 44 | fig.show() 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /examples/mesh_function.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | import pysketcher as ps 6 | from pysketcher.backend.matplotlib import MatplotlibBackend 7 | 8 | 9 | def main() -> None: 10 | u = ps.SketchyFunc3() 11 | Nt = 5 12 | t_mesh = np.linspace(0, 6, Nt + 1) 13 | 14 | # Add 20% space to the left and 30% to the right of the coordinate system 15 | t_axis_extent = t_mesh[-1] - t_mesh[0] 16 | logging.info(t_axis_extent) 17 | t_min = t_mesh[0] - 0.2 * t_axis_extent 18 | logging.info(t_min) 19 | t_max = t_mesh[-1] + 0.3 * t_axis_extent 20 | logging.info(t_max) 21 | u_max = 1.3 * max([u(t) for t in t_mesh]) 22 | logging.info(u_max) 23 | u_min = -0.2 * u_max 24 | logging.info(u_max) 25 | 26 | r = 0.005 * (t_max - t_min) # radius of circles placed at mesh points 27 | # import random; random.seed(12) 28 | perturbations = [0, 0.1, 0.1, 0.2, -0.4, -0.1] 29 | u_points = {} 30 | u_values = [] 31 | for i, t in enumerate(t_mesh): 32 | u_value = u(t) + perturbations[i] 33 | u_values.append(u_value) 34 | circle = ps.Circle(ps.Point(t, u_value), r).set_fill_color(ps.Style.Color.BLACK) 35 | text = ps.Text( 36 | "$u^%d$" % i, 37 | ps.Point(t, u_value) 38 | + (ps.Point(0.0, 3 * r) if i > 0 else ps.Point(-3 * r, 0.0)), 39 | ) 40 | u_points[i] = ps.Composition({"circle": circle, "u_point": text}) 41 | u_discrete = ps.Composition(u_points) 42 | 43 | i_lines = {} 44 | for i in range(1, len(t_mesh)): 45 | i_lines[i] = ps.Line( 46 | ps.Point(t_mesh[i - 1], u_values[i - 1]), ps.Point(t_mesh[i], u_values[i]) 47 | ).set_line_width(1) 48 | interpolant = ps.Composition(i_lines) 49 | 50 | x_axis_extent: float = t_mesh[-1] + 0.2 * t_axis_extent 51 | logging.info(x_axis_extent) 52 | axes = ps.Composition( 53 | { 54 | "x": ps.Axis( 55 | ps.Point(0.0, 0.0), 56 | x_axis_extent, 57 | "$t$", 58 | ), 59 | "y": ps.Axis( 60 | ps.Point(0.0, 0.0), 0.8 * u_max, "$u$", rotation_angle=np.pi / 2 61 | ), 62 | } 63 | ) 64 | 65 | h = 0.03 * u_max # tickmarks height 66 | i_nodes = {} 67 | for i, t in enumerate(t_mesh): 68 | i_nodes[i] = ps.Composition( 69 | { 70 | "node": ps.Line(ps.Point(t, h), ps.Point(t, -h)), 71 | "name": ps.Text("$t_%d$" % i, ps.Point(t, -3.5 * h)), 72 | } 73 | ) 74 | 75 | nodes = ps.Composition(i_nodes) 76 | 77 | fig = ps.Figure(t_min, t_max, u_min, u_max, backend=MatplotlibBackend) 78 | 79 | # Draw t_mesh with discrete u points 80 | illustration = ps.Composition( 81 | dict( 82 | u=u_discrete, 83 | mesh=nodes, 84 | axes=axes, 85 | ) 86 | ) 87 | fig.erase() 88 | fig.add(illustration) 89 | fig.show() 90 | 91 | # Add exact u line (u is a Spline Shape that applies 500 intervals by default 92 | # for drawing the curve) 93 | exact = u.set_line_style(ps.Style.LineStyle.DASHED).set_line_width(1) 94 | 95 | fig.add(exact) 96 | fig.show() 97 | 98 | # Add linear interpolant 99 | fig.add(interpolant) 100 | fig.show() 101 | 102 | # Linear interpolant without exact, smooth line 103 | fig.erase() 104 | fig.add(illustration) 105 | fig.add(interpolant) 106 | fig.show() 107 | 108 | 109 | if __name__ == "__main__": 110 | main() 111 | -------------------------------------------------------------------------------- /examples/oscillator_sketch1.py: -------------------------------------------------------------------------------- 1 | """Draw mechanical vibration system.""" 2 | 3 | import numpy as np 4 | 5 | import pysketcher as ps 6 | from pysketcher.backend.matplotlib import MatplotlibBackend 7 | 8 | L = 12.0 9 | H = L / 6 10 | W = L / 6 11 | 12 | x_max = L 13 | x = 0 14 | 15 | 16 | def make_dashpot(x): 17 | d_start = ps.Point(-L, 2 * H) 18 | d = ps.Dashpot( 19 | start=d_start, 20 | total_length=L + x, 21 | width=W, 22 | bar_length=3 * H / 2, 23 | dashpot_length=L / 2, 24 | piston_pos=H + x, 25 | ) 26 | d = d.rotate(-np.pi / 2, d_start) 27 | return d 28 | 29 | 30 | def make_spring(x): 31 | s_start = ps.Point(-L, 4 * H) 32 | s = ps.Spring(start=s_start, length=L + x, bar_length=3 * H / 2) 33 | s = s.rotate(-np.pi / 2, s_start) 34 | return s 35 | 36 | 37 | def main() -> None: 38 | d = make_dashpot(0) 39 | s = make_spring(0) 40 | 41 | M = ps.Rectangle(ps.Point(0, H), 4 * H, 4 * H).set_line_width(4) 42 | left_wall = ps.Rectangle(ps.Point(-L, 0), H / 10, L).set_fill_pattern( 43 | ps.Style.FillPattern.UP_LEFT_TO_RIGHT 44 | ) 45 | ground = ps.Wall([ps.Point(-L / 2, 0), ps.Point(L, 0)], thickness=-H / 10) 46 | wheel1 = ps.Circle(ps.Point(H, H / 2), H / 2) 47 | wheel2 = wheel1.translate(ps.Point(2 * H, 0)) 48 | 49 | fontsize = 24 50 | text_m = ps.Text("$m$", ps.Point(2 * H, H + 2 * H)) 51 | text_m.style.font_size = fontsize 52 | text_ku = ps.Text("$ku$", ps.Point(-L / 2, H + 4 * H)) 53 | text_ku.style.font_size = fontsize 54 | text_bv = ps.Text("$bu'$", ps.Point(-L / 2, H)) 55 | text_bv.style.font_size = fontsize 56 | x_axis = ps.Axis(ps.Point(2 * H, L), H, "$u(t)$") 57 | x_axis_start = ps.Line( 58 | ps.Point(2 * H, L - H / 4), ps.Point(2 * H, L + H / 4) 59 | ).set_line_width(4) 60 | 61 | model = ps.Composition( 62 | { 63 | "spring": s, 64 | "mass": M, 65 | "left wall": left_wall, 66 | "ground": ground, 67 | "wheel1": wheel1, 68 | "wheel2": wheel2, 69 | "text_m": text_m, 70 | "text_ku": text_ku, 71 | "x_axis": x_axis, 72 | "x_axis_start": x_axis_start, 73 | } 74 | ) 75 | 76 | fig = ps.Figure(-L, x_max, -1, L + H, backend=MatplotlibBackend) 77 | fig.add(model) 78 | 79 | damping = ps.Composition({"dashpot": d, "text_bv": text_bv}) 80 | 81 | # or fig = Composition(dict(fig=fig, dashpot=d, text_bv=text_bv)) 82 | fig.add(damping) 83 | fig.show() 84 | 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /examples/pendulum1.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | 5 | import pysketcher as ps 6 | from pysketcher.backend.matplotlib import MatplotlibBackend 7 | 8 | H = 7.0 9 | W = 6.0 10 | 11 | logging.basicConfig(level=logging.INFO) 12 | 13 | 14 | def main() -> None: 15 | L = 5 * H / 7 # length 16 | P = ps.Point(W / 6, 0.85 * H) # rotation point 17 | a = 2 * np.pi / 9 # angle 18 | 19 | vertical = ps.Line(P, P - ps.Point(0, L)) 20 | path = ps.Arc(P, L, -np.pi / 2, a) 21 | mass_pt = path.end 22 | rod = ps.Line(P, mass_pt) 23 | 24 | mass = ps.Circle(mass_pt, L / 20.0) 25 | theta = ps.AngularDimension( 26 | r"$\theta$", P + ps.Point(0, -L / 4), P + (mass_pt - P).unit_vector * (L / 4), P 27 | ) 28 | theta.extension_lines = False 29 | 30 | rod_vec = rod.end - rod.start 31 | unit_rod_vec = rod_vec.unit_vector 32 | mass_symbol = ps.Text("$m$", mass_pt + unit_rod_vec * (L / 10.0)) 33 | 34 | length = ps.LinearDimension("$L$", mass_pt, P) 35 | gravity = ps.Gravity(start=P + ps.Point(0.8 * L, 0), length=L / 3) 36 | 37 | def set_dashed_thin_blackline(*objects: ps.Shape): 38 | """Set linestyle of objects to dashed, black, width=1.""" 39 | for obj in objects: 40 | obj.set_line_style(ps.Style.LineStyle.DASHED) 41 | obj.set_line_color(ps.Style.Color.BLACK) 42 | obj.set_line_width(1) 43 | 44 | set_dashed_thin_blackline(vertical, path) 45 | mass.style.fill_color = ps.Style.Color.BLUE 46 | 47 | model = ps.Composition( 48 | { 49 | "vertical": vertical, 50 | "path": path, 51 | "theta": theta, 52 | "rod": rod, 53 | "body": mass, 54 | "m": mass_symbol, 55 | "g": gravity, 56 | "L": length, 57 | } 58 | ) 59 | 60 | fig = ps.Figure(0.0, W, 0.0, H, backend=MatplotlibBackend) 61 | fig.add(model) 62 | fig.show() 63 | 64 | vertical2 = ps.Line(rod.start, rod.start + ps.Point(0.0, -L / 3.0)) 65 | set_dashed_thin_blackline(vertical2) 66 | set_dashed_thin_blackline(rod) 67 | angle2 = ps.Arc(rod.start, L / 6, -np.pi / 2, a) 68 | angle2.style.arrow = ps.Style.ArrowStyle.DOUBLE 69 | angle2_label = ps.ArcAnnotation(r"$\theta$", angle2) 70 | 71 | mg_force = ps.Force( 72 | "$mg$", 73 | mass_pt, 74 | mass_pt + ps.Point(0.0, -L / 5.0), 75 | text_position=ps.TextPosition.END, 76 | ) 77 | rod_force = ps.Force( 78 | "$S$", 79 | mass_pt, 80 | mass_pt - rod_vec.unit_vector * (L / 3.0), 81 | text_position=ps.TextPosition.END, 82 | ) 83 | 84 | mass.style.fill_color = ps.Style.Color.BLUE 85 | 86 | body_diagram_shapes = { 87 | "$mg$": mg_force, 88 | "S": rod_force, 89 | "rod": rod, 90 | "vertical": vertical2, 91 | "theta": angle2, 92 | "theta_label": angle2_label, 93 | "body": mass, 94 | "m": mass_symbol, 95 | } 96 | 97 | air_force = ps.Force( 98 | r"${\sim}|v|v$", 99 | mass_pt, 100 | mass_pt + rod_vec.normal * (L / 6.0), 101 | text_position=ps.TextPosition.END, 102 | # spacing = Point(0.04, 0.005), 103 | ) 104 | 105 | body_diagram_shapes["air"] = air_force 106 | 107 | x0y0 = ps.Text("$(x_0,y_0)$", P + ps.Point(-0.4, -0.1)) 108 | 109 | ir = ps.Force( 110 | r"$\vec{i}_r$", 111 | P, 112 | P + rod_vec.unit_vector * (L / 10.0), 113 | text_position=ps.TextPosition.END, 114 | ) 115 | 116 | ith = ps.Force( 117 | r"$\vec{i}_{\theta}$", 118 | P, 119 | P + rod_vec.normal * (L / 10.0), 120 | text_position=ps.TextPosition.END, 121 | ) 122 | 123 | body_diagram_shapes["ir"] = ir 124 | body_diagram_shapes["ith"] = ith 125 | body_diagram_shapes["origin"] = x0y0 126 | 127 | fig.erase() 128 | body_diagram = ps.Composition(body_diagram_shapes) 129 | fig.add(body_diagram) 130 | fig.show() 131 | 132 | 133 | if __name__ == "__main__": 134 | main() 135 | -------------------------------------------------------------------------------- /examples/simple_axis.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pysketcher as ps 4 | from pysketcher.backend.matplotlib import MatplotlibBackend 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | 8 | 9 | def main(): 10 | code = ps.Axis(ps.Point(1, 1), 3, "x") 11 | code2 = ps.Axis(ps.Point(1, 1), 3, "y") 12 | model = ps.Composition(dict(x=code, y=code2)) 13 | 14 | fig = ps.Figure(0, 5, 0, 5, backend=MatplotlibBackend) 15 | fig.add(model) 16 | fig.show() 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /examples/staggered_mesh_function.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import pysketcher as ps 4 | from pysketcher.backend.matplotlib import MatplotlibBackend 5 | 6 | Nt = 5 7 | 8 | 9 | def main(): 10 | u = ps.SketchyFunc3() 11 | t_mesh = np.linspace(0, 6, Nt + 1) 12 | t_mesh_staggered = np.linspace( 13 | 0.5 * (t_mesh[0] + t_mesh[1]), 0.5 * (t_mesh[-2] + t_mesh[-1]), Nt 14 | ) 15 | 16 | # Add 20% space to the left and 30% to the right of the coordinate system 17 | t_axis_extent = t_mesh[-1] - t_mesh[0] 18 | t_min = t_mesh[0] - 0.2 * t_axis_extent 19 | t_max = t_mesh[-1] + 0.3 * t_axis_extent 20 | u_max = 1.3 * max([u(t) for t in t_mesh]) 21 | u_min = -0.2 * u_max 22 | 23 | r = 0.005 * (t_max - t_min) # radius of circles placed at mesh Points 24 | u_discrete = ps.Composition( 25 | { 26 | i: ps.Composition( 27 | dict( 28 | circle=ps.Circle(ps.Point(t, u(t)), r).set_fill_color( 29 | ps.Style.Color.BLACK 30 | ), 31 | u_Point=ps.Text( 32 | "$u_%d$" % i, 33 | ps.Point(t, u(t)) 34 | + (ps.Point(0, 5 * r) if i > 0 else ps.Point(-5 * r, 0)), 35 | ), 36 | ) 37 | ) 38 | for i, t in enumerate(t_mesh) 39 | } 40 | ) 41 | 42 | # u' = v 43 | # v = u.smooth.derivative(n=1) 44 | v = ps.SketchyFunc4() 45 | 46 | v_discrete = ps.Composition( 47 | { 48 | i: ps.Composition( 49 | dict( 50 | circle=ps.Circle(ps.Point(t, v(t)), r).set_fill_color( 51 | ps.Style.Color.RED 52 | ), 53 | v_Point=ps.Text( 54 | r"$v_{%d/2}$" % (2 * i + 1), 55 | ps.Point(t, v(t)) + (ps.Point(0, 5 * r)), 56 | ), 57 | ) 58 | ) 59 | for i, t in enumerate(t_mesh_staggered) 60 | } 61 | ) 62 | 63 | axes = ps.Composition( 64 | dict( 65 | x=ps.Axis(ps.Point(0, 0), t_mesh[-1] + 0.2 * t_axis_extent, "$t$"), 66 | y=ps.Axis(ps.Point(0, 0), 0.8 * u_max, "$u,v$", rotation_angle=np.pi / 2), 67 | ) 68 | ) 69 | 70 | h = 0.03 * u_max # tickmarks height 71 | u_nodes = ps.Composition( 72 | { 73 | i: ps.Composition( 74 | dict( 75 | node=ps.Line(ps.Point(t, h), ps.Point(t, -h)), 76 | name=ps.Text("$t_%d$" % i, ps.Point(t, -3.5 * h)), 77 | ) 78 | ) 79 | for i, t in enumerate(t_mesh) 80 | } 81 | ) 82 | v_nodes = ps.Composition( 83 | { 84 | i: ps.Composition( 85 | dict( 86 | node=ps.Line( 87 | ps.Point(t, h / 1.5), ps.Point(t, -h / 1.5) 88 | ).set_line_color(ps.Style.Color.RED), 89 | name=ps.Text(r"$t_{%d/2}$" % (2 * i + 1), ps.Point(t, -3.5 * h)), 90 | ) 91 | ) 92 | for i, t in enumerate(t_mesh_staggered) 93 | } 94 | ) 95 | illustration = ps.Composition( 96 | dict(u=u_discrete, v=v_discrete, u_mesh=u_nodes, v_mesh=v_nodes, axes=axes) 97 | ) 98 | 99 | fig = ps.Figure(t_min, t_max, u_min, u_max, backend=MatplotlibBackend) 100 | 101 | # Staggered t mesh and u and v Points 102 | fig.add(illustration) 103 | fig.show() 104 | 105 | # Exact u line (u is a Spline Shape that applies 500 intervals by default 106 | # for drawing the curve) 107 | u_exact = u.set_line_style(ps.Style.LineStyle.DASHED).set_line_width(1) 108 | fig.add(u_exact) 109 | fig.show() 110 | 111 | # v = Curve(u.xcoor, v(u.xcoor)) 112 | t_mesh_staggered_fine = np.linspace(t_mesh_staggered[0], t_mesh_staggered[-1], 501) 113 | t_mesh_staggered_points = [ps.Point(x, v(x)) for x in t_mesh_staggered_fine] 114 | v_exact = ( 115 | ps.Curve(t_mesh_staggered_points) 116 | .set_line_style(ps.Style.LineStyle.DASHED) 117 | .set_line_width(1) 118 | ) 119 | fig.add(v_exact) 120 | fig.show() 121 | 122 | 123 | if __name__ == "__main__": 124 | main() 125 | -------------------------------------------------------------------------------- /examples/vehicle.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | R = 1 # radius of wheel 5 | L = 4 # distance between wheels 6 | H = 2 # height of vehicle body 7 | w_1 = 5 # position of front wheel 8 | 9 | 10 | def main() -> None: 11 | wheel1 = ( 12 | ps.Circle(ps.Point(w_1, R), R) 13 | .set_fill_color(ps.Style.Color.BLUE) 14 | .set_line_width(6) 15 | ) 16 | wheel2 = wheel1.translate(ps.Point(L, 0)) 17 | under = ps.Rectangle(ps.Point(w_1 - 2 * R, 2 * R), 2 * R + L + 2 * R, H) 18 | under.style.fill_color = ps.Style.Color.RED 19 | under.style.line_color = ps.Style.Color.RED 20 | over = ps.Rectangle(ps.Point(w_1, 2 * R + H), 2.5 * R, 1.25 * H).set_fill_color( 21 | ps.Style.Color.WHITE 22 | ) 23 | over.style.line_width = 14 24 | over.style.line_color = ps.Style.Color.RED 25 | over.style.fill_pattern = ps.Style.FillPattern.UP_RIGHT_TO_LEFT 26 | 27 | ground = ps.Wall([ps.Point(w_1 - L, 0), ps.Point(w_1 + 3 * L, 0)], -0.3 * R) 28 | ground.style.fill_pattern = ps.Style.FillPattern.UP_LEFT_TO_RIGHT 29 | 30 | model = ps.Composition( 31 | { 32 | "wheel1": wheel1, 33 | "wheel2": wheel2, 34 | "under": under, 35 | "over": over, 36 | "ground": ground, 37 | } 38 | ) 39 | 40 | fig = ps.Figure( 41 | 0, w_1 + 2 * L + 3 * R, -1, 2 * R + 3 * H, backend=MatplotlibBackend 42 | ) 43 | fig.add(model) 44 | fig.show() 45 | 46 | 47 | if __name__ == "__main__": 48 | main() 49 | -------------------------------------------------------------------------------- /examples/vehicle_dim.py: -------------------------------------------------------------------------------- 1 | import pysketcher as ps 2 | from pysketcher.backend.matplotlib import MatplotlibBackend 3 | 4 | R = 1 # radius of wheel 5 | L = 4 # distance between wheels 6 | H = 2 # height of vehicle body 7 | w_1 = 5 # position of front wheel 8 | 9 | 10 | # TODO : draw grids 11 | def main() -> None: 12 | c = ps.Point(w_1, R) 13 | 14 | wheel1 = ps.Circle(c, R) 15 | wheel2 = wheel1.translate(ps.Point(L, 0)) 16 | under = ps.Rectangle(ps.Point(w_1 - 2 * R, 2 * R), 2 * R + L + 2 * R, H) 17 | over = ps.Rectangle(ps.Point(w_1, 2 * R + H), 2.5 * R, 1.25 * H).set_fill_color( 18 | ps.Style.Color.WHITE 19 | ) 20 | ground = ps.Wall([ps.Point(w_1 - L, 0), ps.Point(w_1 + 3 * L, 0)], -0.3 * R) 21 | ground.style.fill_pattern = ps.Style.FillPattern.UP_RIGHT_TO_LEFT 22 | 23 | vehicle = ps.Composition( 24 | { 25 | "wheel1": wheel1, 26 | "wheel2": wheel2, 27 | "under": under, 28 | "over": over, 29 | "ground": ground, 30 | } 31 | ) 32 | 33 | vehicle.style.line_color = ps.Style.Color.RED 34 | 35 | wheel1_dim = ps.LinearDimension("$w_1$", c + ps.Point(2, 0.25), c) 36 | hdp = w_1 + L + 3 * R # horizontal dimension position 37 | R_dim = ps.LinearDimension("$R$", ps.Point(hdp, 0), ps.Point(hdp, R)) 38 | H_dim = ps.LinearDimension("$H$", ps.Point(hdp, 2 * R), ps.Point(hdp, 2 * R + H)) 39 | H2_dim = ps.LinearDimension( 40 | "$\\frac{5}{4}H$", ps.Point(hdp, 2 * R + H), ps.Point(hdp, 2 * R + (9 / 4) * H) 41 | ) 42 | 43 | vdp = 2 * R + H + 3 / 2 * H 44 | R2_dim = ps.LinearDimension("$2R$", ps.Point(w_1 - 2 * R, vdp), ps.Point(w_1, vdp)) 45 | L_dim = ps.LinearDimension("$L$", ps.Point(w_1, vdp), ps.Point(w_1 + L, vdp)) 46 | R3_dim = ps.LinearDimension( 47 | "$2R$", ps.Point(w_1 + L, vdp), ps.Point(w_1 + L + 2 * R, vdp) 48 | ) 49 | 50 | dimensions = ps.Composition( 51 | { 52 | "wheel1_dim": wheel1_dim, 53 | "R_dim": R_dim, 54 | "H_dim": H_dim, 55 | "H2_dim": H2_dim, 56 | "R2_dim": R2_dim, 57 | "L_dim": L_dim, 58 | "R3_dim": R3_dim, 59 | } 60 | ) 61 | 62 | model = ps.Composition({"vehicle": vehicle, "dimensions": dimensions}) 63 | 64 | figure = ps.Figure( 65 | 0, w_1 + 2 * L + 3 * R, -1, 2 * R + 3 * H, backend=MatplotlibBackend 66 | ) 67 | figure.add(model) 68 | figure.show() 69 | 70 | 71 | if __name__ == "__main__": 72 | main() 73 | -------------------------------------------------------------------------------- /examples/wheel_on_inclined_plane.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import numpy as np 4 | 5 | from pysketcher import ( 6 | Angle, 7 | Arc, 8 | ArcAnnotation, 9 | Axis, 10 | Circle, 11 | Figure, 12 | Force, 13 | Line, 14 | Point, 15 | Shape, 16 | Style, 17 | TextPosition, 18 | Wall, 19 | ) 20 | from pysketcher.backend.matplotlib import MatplotlibBackend 21 | from pysketcher.composition import Composition 22 | 23 | 24 | def main(): 25 | theta = np.pi / 6 26 | L = 10.0 27 | a = 1.0 28 | x_min = 0.0 29 | y_min = -3.0 30 | 31 | B = Point(a + L, 0) 32 | A = Point(a, np.tan(theta) * L) 33 | 34 | wall = Wall([A, B], thickness=-0.25) 35 | wall.style.fill_pattern = Style.FillPattern.UP_LEFT_TO_RIGHT 36 | 37 | angle = Arc(center=B, radius=3, start_angle=np.pi - theta, arc_angle=theta) 38 | angleLabel = ArcAnnotation(r"$\theta$", angle) 39 | angle.style.line_color = Style.Color.BLACK 40 | angle.style.line_width = 1 41 | 42 | ground = Line(Point(B.x - L / 10.0, 0), Point(B.x - L / 2.0, 0)) 43 | ground.style.line_color = Style.Color.BLACK 44 | ground.style.line_style = Style.LineStyle.DASHED 45 | ground.style.line_width = 1 46 | 47 | r = 1.0 # radius of wheel 48 | help_line = Line(A, B) 49 | x = a + 3 * L / 10.0 50 | y = help_line(x=x) 51 | contact = Point(x, y) 52 | normal_vec = Point(np.sin(theta), np.cos(theta)) 53 | c = contact + normal_vec * r 54 | outer_wheel = ( 55 | Circle(c, r).set_line_color(Style.Color.BLUE).set_fill_color(Style.Color.BLUE) 56 | ) 57 | hole = ( 58 | Circle(c, r / 2.0) 59 | .set_line_color(Style.Color.BLUE) 60 | .set_fill_color(Style.Color.WHITE) 61 | ) 62 | wheel = Composition({"outer": outer_wheel, "inner": hole}) 63 | 64 | N = Force( 65 | "$N$", contact - normal_vec * 2 * r, contact, text_position=TextPosition.START 66 | ) 67 | N.style.line_color = Style.Color.BLACK 68 | 69 | # text_alignment='left') 70 | mg = Force("$mg$", c, c + Point(0, -3 * r), text_position=TextPosition.END) 71 | 72 | x_const = Line(contact, contact + Point(0, 4)) 73 | x_const.style.line_style = Style.LineStyle.DOTTED 74 | x_const = x_const.rotate(-theta, contact) 75 | 76 | x_axis = Axis( 77 | start=contact + normal_vec * 3.0 * r, 78 | length=4 * r, 79 | label="$x$", 80 | rotation_angle=Angle(-theta), 81 | ) 82 | 83 | body = Composition({"wheel": wheel, "N": N, "mg": mg}) 84 | fixed = Composition( 85 | { 86 | "angle": angle, 87 | "angle_label": angleLabel, 88 | "inclined wall": wall, 89 | "wheel": wheel, 90 | "ground": ground, 91 | "x start": x_const, 92 | "x axis": x_axis, 93 | } 94 | ) 95 | 96 | model = Composition({"fixed elements": fixed, "body": body}) 97 | 98 | fig = Figure(x_min, x_min + 1.5 * L, y_min, y_min + L, backend=MatplotlibBackend) 99 | 100 | fig.add(model) 101 | fig.show() 102 | time.sleep(1) 103 | tangent_vec = Point(normal_vec.y, -normal_vec.x) 104 | 105 | def position(t): 106 | """Position of center point of wheel.""" 107 | return c + tangent_vec * 7 * t**2 108 | 109 | def move(fig: Shape, t: float, dt: float = None) -> fig: 110 | x = position(t) 111 | x0 = position(t - dt) 112 | displacement = x - x0 113 | return fig["body"].translate(displacement) 114 | 115 | 116 | if __name__ == "__main__": 117 | main() 118 | -------------------------------------------------------------------------------- /fig/beam2_3.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/beam2_3.pdf -------------------------------------------------------------------------------- /fig/beam2_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/beam2_3.png -------------------------------------------------------------------------------- /fig/integral_comic_strip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/integral_comic_strip.png -------------------------------------------------------------------------------- /fig/integral_noncomic_strip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/integral_noncomic_strip.png -------------------------------------------------------------------------------- /fig/pendulum.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/pendulum.mp4 -------------------------------------------------------------------------------- /fig/pendulum2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/fig/pendulum2.png -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import glob 2 | from nox import session, Session, options 3 | import shutil 4 | 5 | 6 | options.sessions = ["lint", "tests", "examples"] 7 | main_version = ["3.13"] 8 | supported_versions = main_version + ["3.11", "3.12"] 9 | locations = "pysketcher", "tests", "examples", "docs", "noxfile.py" 10 | 11 | 12 | def _lint(session: Session, install: bool = True) -> None: 13 | if install: 14 | session.install(".[lint]") 15 | session.run("flake8") 16 | 17 | 18 | @session(python=False) 19 | def local_lint(session: Session) -> None: 20 | _lint(session, install=False) 21 | 22 | 23 | @session(python=main_version) 24 | def lint(session: Session) -> None: 25 | _lint(session, install=True) 26 | 27 | 28 | def _tests( 29 | session: Session, cov_report: str = "xml:coverage.xml", install: bool = True 30 | ) -> None: 31 | if install: 32 | session.install(".[tests]") 33 | session.run( 34 | "pytest", "--cov", "--cov-report", cov_report, "--junitxml=test-results.xml" 35 | ) 36 | 37 | 38 | @session(python=False) 39 | def local_tests(session: Session): 40 | _tests(session, "html", False) 41 | 42 | 43 | @session(python=supported_versions) 44 | def tests(session: Session): 45 | _tests(session) 46 | 47 | 48 | @session(python=False) 49 | def install(session: Session): 50 | session.run("pip", "install", "-e", ".[lint,tests,documentation,build]") 51 | 52 | 53 | @session(python=supported_versions) 54 | def build(session: Session): 55 | session.run("pip", "install", ".[build]") 56 | session.run("python", "-m", "build") 57 | 58 | 59 | def _examples(session: Session, install: bool = False) -> None: 60 | if install: 61 | session.install(".[tests]") 62 | 63 | session.run( 64 | "pytest", 65 | "--cov", 66 | "--cov-append", 67 | "-o", 68 | "testpaths=examples", 69 | "-o", 70 | "python_files=*.py", 71 | "-o", 72 | "python_functions=main", 73 | ) 74 | 75 | 76 | @session(python=False) 77 | def local_examples(session: Session): 78 | _examples(session) 79 | 80 | 81 | @session(python=supported_versions) 82 | def examples(session: Session): 83 | _examples(session, install=True) 84 | 85 | 86 | def _documentation(session: Session, install: bool = False) -> None: 87 | """Build the documentation.""" 88 | if install: 89 | session.install(".[documentation,tests]") 90 | session.run("pytest", "pysketcher") # generate the images by running the docstrings 91 | for file in glob.glob("./pysketcher/images/*.png"): 92 | print(f"{file}") 93 | shutil.copy(file, "./docs/images") 94 | session.run("sphinx-build", "docs", "docs/_build") 95 | 96 | 97 | @session(python=main_version) 98 | def documentation(session: Session): 99 | _documentation(session, install=True) 100 | 101 | 102 | @session(python=False) 103 | def local_documentation(session: Session): 104 | _documentation(session) 105 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pysketcher" 3 | description = "Geometric Sketching Utility for Python" 4 | readme = "README.rst" 5 | requires-python = ">=3.11,<4" 6 | license = { file = "LICENSE" } 7 | authors = [ 8 | { name = "Richard Vodden", email = "richard@vodden.com" }, 9 | { name = "Hans Petter Langtangen" } 10 | ] 11 | keywords = ['sketch','graphics','scientific','engineering','geometry'] 12 | dependencies = [ 13 | "numpy>=1.25,<2.3", 14 | "matplotlib>=3.7.1,<3.11.0", 15 | "scipy>=1.10.1,<1.16.0", 16 | "celluloid~=0.2.0" 17 | ] 18 | dynamic = ["version"] 19 | 20 | [project.optional-dependencies] 21 | build = [ 22 | "build", 23 | "twine", 24 | "nox" 25 | ] 26 | tests = [ 27 | "hypothesis>=6.104.1,<6.136.0", 28 | "pytest>=8.2.2,<8.5.0", 29 | "coverage>=7.2.7,<7.9.0", 30 | "mypy>=0.991,<2.0", 31 | "wheel>=0.38.4,<0.46.0", 32 | "pytest-cov>=4.0,<6.2", 33 | "pytest-timeout>=2.1,<2.5", 34 | "nox-poetry>=1.0.2,<1.3.0", 35 | "flake8-docstrings>=1.6,<1.8", 36 | "flake8-bugbear>=24.4.26,<24.13.0", 37 | "codecov~=2.1.10" 38 | ] 39 | lint = [ 40 | "pylint>=3.2.5,<3.4.0", 41 | "flake8>=5.0.4,<7.3.0", 42 | "pydocstyle>=6.1.1,<6.4.0", 43 | "black>=23.3,<25.2", 44 | "flake8-black~=0.3.6", 45 | "flake8-import-order~=0.18.2", 46 | "flake8-bandit~=4.1.1", 47 | "darglint>=1.7,<1.9", 48 | "blackdoc~=0.3", 49 | ] 50 | precommit = [ 51 | "pre-commit>=2.21,<4.3", 52 | "commitizen>=2.39.1,<4.9.0", 53 | ] 54 | documentation = [ 55 | "sphinx>=7.3.7,<8.3.0", 56 | "furo>=2023.5.20,<2024.9.0", 57 | "sphinx-autodoc-typehints>=2.2.2,<3.3.0", 58 | "recommonmark~=0.7.1", 59 | ] 60 | 61 | [project.urls] 62 | repository = "https://github.com/rvodden/pysketcher.git" 63 | homepage = "https://github.com/rvodden/pysketcher" 64 | 65 | [build-system] 66 | build-backend = "setuptools.build_meta" 67 | requires = [ 68 | "setuptools>=61.0.0", 69 | "setuptools-scm", 70 | "setuptools-git-versioning", 71 | "nox", 72 | ] 73 | 74 | [tool.setuptools.packages.find] 75 | where = ["."] 76 | include = ["pysketcher"] 77 | exclude = ["tests","figs","examples","docs"] 78 | 79 | [tool.setuptools-git-versioning] 80 | enabled = true 81 | 82 | [tool.pytest.ini_options] 83 | testpaths = ["tests", "pysketcher"] 84 | python_files = "test_*.py" 85 | python_functions = "test_*" 86 | junit_duration_report = "call" 87 | junit_suite_name = "pysketcher" 88 | junit_family = "xunit1" 89 | addopts = "--doctest-modules" 90 | 91 | [tool.coverage.run] 92 | branch = true 93 | source = ["pysketcher/"] 94 | 95 | [tool.coverage.report] 96 | show_missing = true 97 | 98 | [tool.isort] 99 | line_length = 88 100 | multi_line_output = 3 101 | include_trailing_comma = true 102 | known_third_party = "celery,django,environ,pyquery,pytz,redis,requests,rest_framework" 103 | 104 | [tool.mypy] 105 | # follow and type check all modules, including third-party ones 106 | follow_imports = "normal" 107 | # precede all errors with “note” messages explaining the context of the error 108 | show_error_context = true 109 | # This is the last resort solution, we should fine-tune it with 110 | # specific sections [mypy-] for libraries that 111 | # have missing stubs or issues with imports 112 | # https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports 113 | ignore_missing_imports = true 114 | # Check all defs, even untyped ones (with less precise semantics) 115 | check_untyped_defs = true 116 | # All functions/methods should have complete signatures 117 | # (this basically disallows gradual typing for all module-level definitions) 118 | disallow_incomplete_defs = true 119 | # all writes to cache should be discarded 120 | # (it's different from incremental mode) 121 | cache_dir = "/dev/null" 122 | # report an error whenever the code uses an unnecessary cast 123 | # that can safely be removed. 124 | warn_redundant_casts = true 125 | # warn about unused [mypy-] config file sections. 126 | warn_unused_configs = true 127 | # find gaps and omissions in type stubs, including third-party ones 128 | warn_incomplete_stub = true 129 | # helps to find any `# type: ignore` annotations that we no longer need 130 | warn_unused_ignores = true 131 | # All arguments that allow None should be annotated as Optional[T] 132 | strict_optional = true 133 | # stop treating arguments with a None default value as having 134 | # an implicit Optional[T] type 135 | no_implicit_optional = true 136 | -------------------------------------------------------------------------------- /pysketcher/__init__.py: -------------------------------------------------------------------------------- 1 | """Tool for creating sketches of physical and mathematical problems in Python code.""" 2 | from importlib.metadata import PackageNotFoundError, version 3 | 4 | try: 5 | __version__ = version(__name__) 6 | except PackageNotFoundError: 7 | pass 8 | 9 | from pysketcher._angle import Angle 10 | from pysketcher._arc import Arc 11 | from pysketcher._arrow import Arrow, DoubleArrow 12 | from pysketcher._axis import Axis 13 | from pysketcher._circle import Circle 14 | from pysketcher._cubic_bezier_curve import CubicBezier 15 | from pysketcher._curve import Curve 16 | from pysketcher._dashpot import Dashpot 17 | from pysketcher._drawable import Drawable 18 | from pysketcher._figure import Figure 19 | from pysketcher._force import Force, Gravity 20 | from pysketcher._line import Line 21 | from pysketcher._moment import Moment 22 | from pysketcher._point import Point 23 | from pysketcher._rectangle import Rectangle 24 | from pysketcher._shape import Shape 25 | from pysketcher._simple_support import SimpleSupport 26 | from pysketcher._sketchy_func import ( 27 | SketchyFunc1, 28 | SketchyFunc2, 29 | SketchyFunc3, 30 | SketchyFunc4, 31 | ) 32 | from pysketcher._spline import Spline 33 | from pysketcher._spring import Spring 34 | from pysketcher._style import Style, TextStyle 35 | from pysketcher._text import Text 36 | from pysketcher._triangle import Triangle 37 | from pysketcher._uniform_load import UniformLoad 38 | from pysketcher._velocity_profile import VelocityProfile 39 | from pysketcher._wall import Wall 40 | from pysketcher._wheel import Wheel 41 | from pysketcher.annotation import ArcAnnotation, LineAnnotation, TextPosition 42 | from pysketcher.composition import Composition 43 | from pysketcher.dimension import AngularDimension, LinearDimension, RadialDimension 44 | 45 | __all__ = [ 46 | "AngularDimension", 47 | "Axis", 48 | "Angle", 49 | "Arc", 50 | "ArcAnnotation", 51 | "Arrow", 52 | "DoubleArrow", 53 | "Circle", 54 | "CubicBezier", 55 | "Composition", 56 | "Curve", 57 | "Dashpot", 58 | "Drawable", 59 | "Figure", 60 | "Force", 61 | "Gravity", 62 | "Line", 63 | "LineAnnotation", 64 | "LinearDimension", 65 | "Moment", 66 | "Point", 67 | "RadialDimension", 68 | "Rectangle", 69 | "Shape", 70 | "SimpleSupport", 71 | "SketchyFunc1", 72 | "SketchyFunc2", 73 | "SketchyFunc3", 74 | "SketchyFunc4", 75 | "Spline", 76 | "Spring", 77 | "Style", 78 | "TextStyle", 79 | "TextPosition", 80 | "Text", 81 | "Triangle", 82 | "UniformLoad", 83 | "VelocityProfile", 84 | "Wall", 85 | "Wheel", 86 | ] 87 | -------------------------------------------------------------------------------- /pysketcher/_angle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | class Angle(float): 5 | r"""A representation of a 2-Dimensional Angle. 6 | 7 | An ``Angle`` is a float which is limited in value to between :math:`\pi` 8 | and :math:`-\pi`. Values outside of this range have :math:`2\pi` either added 9 | or subtracted repeatedly until they are within this bound. 10 | 11 | Args: 12 | value: The value of the angle to create. 13 | 14 | Examples: 15 | >>> Angle(1.0) 16 | Angle(1.0) 17 | 18 | >>> Angle(np.pi / 2) 19 | Angle(1.5707963267948966) 20 | 21 | >>> Angle(123) 22 | Angle(-2.6637061435917246) 23 | 24 | >>> Angle(2 * np.pi) 25 | Angle(0.0) 26 | """ 27 | 28 | def __new__(cls, value: float) -> "Angle": 29 | """Creates a new Angle.""" 30 | value = cls._normalize(value) 31 | return super(cls, cls).__new__(cls, value) 32 | 33 | @staticmethod 34 | def _normalize(value: float): 35 | retval = value - 2 * np.pi * (np.ceil((value + np.pi) / (2 * np.pi)) - 1) 36 | # TODO: this step should not be necessary - what is wrong with the above? 37 | if not (-np.pi < retval <= np.pi): 38 | retval = Angle._normalize(retval) 39 | return retval 40 | 41 | def __add__(self, other: float) -> "Angle": 42 | """Returns the sum of this ``Angle`` with the provided ``Angle``.""" 43 | res = super(self.__class__, self).__add__(other) 44 | return self.__class__(res) 45 | 46 | def __sub__(self, other: float) -> "Angle": 47 | """Returns the difference between this ``Angle`` and the provided ``Angle``.""" 48 | res = super(self.__class__, self).__sub__(other) 49 | return self.__class__(res) 50 | 51 | def __mul__(self, other: float) -> "Angle": 52 | """Scales the ``Angle`` by a factor of the provided value.""" 53 | res = super(self.__class__, self).__mul__(other) 54 | return self.__class__(res) 55 | 56 | def __truediv__(self, other: float) -> "Angle": 57 | """Divides the ``Angle`` by the provided factor.""" 58 | res = super(self.__class__, self).__truediv__(other) 59 | return self.__class__(res) 60 | 61 | def __str__(self: float) -> str: 62 | """Provides a string rendition of the value of the ``Angle``.""" 63 | return f"{float(self)}" 64 | 65 | def __repr__(self: float) -> str: 66 | """Provides a string rendition of the value of the ``Angle`` and class.""" 67 | return f"Angle({float(self)})" 68 | -------------------------------------------------------------------------------- /pysketcher/_arc.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pysketcher._angle import Angle 4 | from pysketcher._curve import Curve 5 | from pysketcher._point import Point 6 | 7 | 8 | class Arc(Curve): 9 | """A representation of a continuous connected subset of a circle. 10 | 11 | Args: 12 | center: The center of the Arc. 13 | radius: The radius of the Arc. 14 | start_angle: The angle from the +ve horizontal where the Arc should start. 15 | arc_angle: The angle (from the start_angle) where the Arc should end. 16 | resolution: The number of points in the Arc. 17 | 18 | Examples: 19 | >>> arc = ps.Arc(ps.Point(0.0, 0.0), 1.0, Angle(0.0), Angle(np.pi / 2)) 20 | >>> fig = ps.Figure(-0.5, 1.5, -0.5, 1.5, backend=MatplotlibBackend) 21 | >>> fig.add(arc) 22 | >>> fig.save("pysketcher/images/arc.png") 23 | 24 | .. figure:: images/arc.png 25 | :alt: An example of an Arc. 26 | :figclass: align-center 27 | 28 | An example of an ``Arc``. 29 | """ 30 | 31 | _center: Point 32 | _radius: float 33 | _start_angle: Angle 34 | _arc_angle: Angle 35 | _resolution: int 36 | 37 | def __init__( 38 | self, 39 | center: Point, 40 | radius: float, 41 | start_angle: Angle, 42 | arc_angle: Angle, 43 | resolution: int = 180, 44 | ): 45 | # Must record some parameters for __call__ 46 | self._center = center 47 | self._radius = radius 48 | self._start_angle = Angle(start_angle) 49 | self._arc_angle = Angle(arc_angle) 50 | self._resolution = resolution 51 | 52 | if self._arc_angle == 0.0: 53 | # assume a full circle 54 | ts = np.linspace(0.0, 2.0 * np.pi, resolution + 1) 55 | else: 56 | ts = np.linspace(0.0, self._arc_angle, resolution + 1) 57 | 58 | points = [self(t) for t in ts] 59 | super().__init__(points) 60 | 61 | def __call__(self, theta: Angle) -> Point: 62 | """Provides a point on the arc ``theta`` of the way around. 63 | 64 | Args: 65 | theta: The angle from the ``start_angle`` from which the point should 66 | be taken. 67 | 68 | Returns: 69 | the point ``theta`` of the way around the arc. 70 | 71 | Raises: 72 | ValueError: if ``theta`` is beyond the bounds of the arc. 73 | """ 74 | if 0.0 < self._arc_angle < theta or theta < self._arc_angle < 0.0: 75 | raise ValueError( 76 | f"Theta ({theta}) is outside the bounds " 77 | "of the arc (0.0 , {self._arc_angle})" 78 | ) 79 | 80 | iota = Angle(self.start_angle + theta) 81 | ret_point = Point( 82 | self.center.x + self.radius * np.cos(iota), 83 | self.center.y + self.radius * np.sin(iota), 84 | ) 85 | return ret_point 86 | 87 | @property 88 | def start_angle(self) -> Angle: 89 | """The angle from the +ve horizontal from where the arc starts.""" 90 | return self._start_angle 91 | 92 | @property 93 | def arc_angle(self) -> Angle: 94 | """The angle from the ``start_angle`` to which the arc ends.""" 95 | return self._arc_angle 96 | 97 | @property 98 | def end_angle(self) -> Angle: 99 | """The angle from the +ve horizontal to which the arc ends.""" 100 | return self._start_angle + self._arc_angle 101 | 102 | @property 103 | def radius(self) -> float: 104 | """The radius of the arc.""" 105 | return self._radius 106 | 107 | @property 108 | def center(self) -> Point: 109 | """The center of the arc.""" 110 | return self._center 111 | 112 | @property 113 | def start(self) -> Point: 114 | """The point at which the arc starts.""" 115 | return self(0.0) 116 | 117 | @property 118 | def end(self) -> Point: 119 | """The point at which the arc ends.""" 120 | return self(self.arc_angle) 121 | 122 | @property 123 | def mid(self) -> Point: 124 | """The middle of the arc.""" 125 | return self(self.arc_angle / 2) 126 | 127 | def translate(self, vec: Point) -> "Arc": 128 | """Translates the arc by the specified vector. 129 | 130 | Args: 131 | vec: The vector through which the arc should be translated. 132 | 133 | Returns: 134 | A copy of the arc which has been translated by ``vec``. 135 | """ 136 | arc = Arc( 137 | self._center + vec, 138 | self._radius, 139 | self._start_angle, 140 | self._arc_angle, 141 | self._resolution, 142 | ) 143 | arc.style = self.style 144 | return arc 145 | -------------------------------------------------------------------------------- /pysketcher/_arrow.py: -------------------------------------------------------------------------------- 1 | from pysketcher._line import Line 2 | from pysketcher._point import Point 3 | from pysketcher._style import Style 4 | 5 | 6 | class Arrow(Line): 7 | """Draw an arrow as Line with arrow pointing towards end. 8 | 9 | Args: 10 | start: The start of the arrow. 11 | end: The end of the arrow. 12 | 13 | Examples: 14 | >>> up_arrow = ps.Arrow(ps.Point(0.0, 1.0), ps.Point(0.0, 3.0)) 15 | >>> down_arrow = ps.Arrow(ps.Point(0.0, -1.0), ps.Point(0.0, -3.0)) 16 | >>> left_arrow = ps.Arrow(ps.Point(-1.0, 0.0), ps.Point(-3.0, 0.0)) 17 | >>> right_arrow = ps.Arrow( 18 | ... ps.Point(1.0, 0.0), 19 | ... ps.Point(3.0, 0.0), 20 | ... ) 21 | >>> fig = ps.Figure(-4.0, 4.0, -4.0, 4.0, backend=MatplotlibBackend) 22 | >>> fig.add(up_arrow) 23 | >>> fig.add(down_arrow) 24 | >>> fig.add(left_arrow) 25 | >>> fig.add(right_arrow) 26 | >>> fig.save("pysketcher/images/arrow.png") 27 | 28 | .. figure:: images/arrow.png 29 | :alt: An example of Arrow. 30 | :figclass: align-center 31 | 32 | An example of ``Arrow``. 33 | """ 34 | 35 | def __init__(self, start: Point, end: Point): 36 | super().__init__(start, end) 37 | self.style.arrow = Style.ArrowStyle.END 38 | 39 | 40 | class DoubleArrow(Line): 41 | """Draw an arrow as Line with arrow pointing towards start and end. 42 | 43 | Args: 44 | start: The start of the double arrow. 45 | end: The end of the double arrow. 46 | 47 | Examples: 48 | >>> double_arrow = ps.DoubleArrow( 49 | ... ps.Point(1.0, 1.0), 50 | ... ps.Point( 51 | ... 3.0, 52 | ... 1.0, 53 | ... ), 54 | ... ) 55 | >>> fig = ps.Figure(0.0, 4.0, 0.0, 2.0, backend=MatplotlibBackend) 56 | >>> fig.add(double_arrow) 57 | >>> fig.save("pysketcher/images/double_arrow.png") 58 | 59 | .. figure:: images/double_arrow.png 60 | :alt: An example of DoubleArrow. 61 | :figclass: align-center 62 | 63 | An example of ``DoubleArrow``. 64 | """ 65 | 66 | def __init__(self, start: Point, end: Point): 67 | super().__init__(start, end) 68 | self.style.arrow = Style.ArrowStyle.DOUBLE 69 | -------------------------------------------------------------------------------- /pysketcher/_axis.py: -------------------------------------------------------------------------------- 1 | from pysketcher._angle import Angle 2 | from pysketcher._arrow import Arrow 3 | from pysketcher._point import Point 4 | from pysketcher.annotation import LineAnnotation 5 | from pysketcher.composition import Composition 6 | 7 | 8 | class Axis(Composition): 9 | """A representation of a axis. 10 | 11 | Draw axis from start with `length` to the right 12 | (x axis). Place label at the end of the arrow tip. 13 | Then return `rotation_angle` (in degrees). 14 | The `label_spacing` denotes the space between the label 15 | and the arrow tip as a fraction of the length of the plot 16 | in x direction. A tuple can be given to adjust the position 17 | in both the x and y directions (with one parameter, the 18 | x position is adjusted). 19 | 20 | Args: 21 | start: The start of the ``Axis``. 22 | length: The length of the ``Axis``. 23 | label: A text label for the ``Axis``. 24 | rotation_angle: The ``Angle`` 25 | """ 26 | 27 | def __init__( 28 | self, 29 | start: Point, 30 | length: float, 31 | label: str, 32 | rotation_angle: Angle = Angle(0.0), # noqa: B008 33 | ): 34 | arrow = Arrow(start, start + Point(length, 0)).rotate(rotation_angle, start) 35 | # should increase spacing for downward pointing axis 36 | label = LineAnnotation(label, arrow) 37 | 38 | super().__init__({"arrow": arrow, "label": label}) 39 | -------------------------------------------------------------------------------- /pysketcher/_circle.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pysketcher._angle import Angle 4 | from pysketcher._arc import Arc 5 | from pysketcher._point import Point 6 | 7 | 8 | class Circle(Arc): 9 | """A representation of a 2D circle. 10 | 11 | Args: 12 | center: The center of the circle. 13 | radius: The radius of the circle. 14 | 15 | Examples: 16 | >>> circle = ps.Circle(ps.Point(1.5, 1.5), 1) 17 | >>> fig = ps.Figure(0, 3, 0, 3, backend=MatplotlibBackend) 18 | >>> fig.add(circle) 19 | >>> fig.save("pysketcher/images/circle.png") 20 | 21 | .. figure:: images/circle.png 22 | :alt: An example of Circle. 23 | :figclass: align-center 24 | 25 | An example of ``Circle``. 26 | """ 27 | 28 | def __init__(self, center: Point, radius: float, resolution=180): 29 | super().__init__(center, radius, Angle(0), 2 * np.pi, resolution) 30 | -------------------------------------------------------------------------------- /pysketcher/_cubic_bezier_curve.py: -------------------------------------------------------------------------------- 1 | import math 2 | from typing import Callable, List, Tuple 3 | 4 | import numpy as np 5 | 6 | from pysketcher._curve import Curve 7 | from pysketcher._point import Point 8 | 9 | 10 | class CubicBezier(Curve): 11 | """A cubic bezier curve implementation. 12 | 13 | Examples: 14 | >>> s = ps.CubicBezier( 15 | ... ps.Point(0, 0), [(ps.Point(1, 1), ps.Point(2.5, 0.5), ps.Point(2, 2.5))] 16 | ... ) 17 | >>> fig = ps.Figure(-0.5, 3.0, -0.5, 3.0, backend=MatplotlibBackend) 18 | >>> fig.add(s) 19 | >>> fig.save("pysketcher/images/cubic_bezier.png") 20 | 21 | .. figure:: images/cubic_bezier.png 22 | :alt: An example of a CubicBezier. 23 | :figclass: align-center 24 | 25 | An example of ``CubicBezier``. 26 | """ 27 | 28 | _input_points: List[Tuple[Point, Point, Point, Point]] 29 | _segments: List[Callable[[float], Point]] 30 | _points: List[Point] 31 | 32 | def __init__( 33 | self, 34 | start: Point, 35 | points: List[Tuple[Point, Point, Point]], 36 | resolution: int = 50, 37 | ): 38 | start_point = start 39 | self._input_points = [] 40 | self._segments = [] 41 | for pts in points: 42 | self._input_points.append((start_point,) + pts) 43 | self._segments.append(self._segment(start_point, pts[0], pts[1], pts[2])) 44 | start_point = pts[2] 45 | 46 | ts = np.linspace(0, len(self._segments), resolution, endpoint=False) 47 | self._points = [self.__call__(t) for t in ts] 48 | last_point = points[len(points) - 1][2] 49 | self._points.append(last_point) 50 | super().__init__(self._points) 51 | 52 | @staticmethod 53 | def _segment( 54 | p0: Point, p1: Point, p2: Point, p3: Point 55 | ) -> Callable[[float], Point]: 56 | def _bernstein_cubic(a: List[float], t: float) -> float: 57 | return ( 58 | (1 - t) ** 3 * a[0] 59 | + (1 - t) ** 2 * 3 * t * a[1] 60 | + (1 - t) * 3 * t**2 * a[2] 61 | + t**3 * a[3] 62 | ) 63 | 64 | def _segment_function(t: float) -> Point: 65 | x = _bernstein_cubic([p0.x, p1.x, p2.x, p3.x], t) 66 | y = _bernstein_cubic([p0.y, p1.y, p2.y, p3.y], t) 67 | return Point(x, y) 68 | 69 | return _segment_function 70 | 71 | @property 72 | def start(self) -> Point: 73 | """The first point of the curve.""" 74 | return self._points[0] 75 | 76 | @property 77 | def end(self) -> Point: 78 | """The last point of the curve.""" 79 | return self._points[-1] 80 | 81 | def __call__(self, t: float) -> Point: 82 | """Given a parameter, ``t``, returns the point on the curve.""" 83 | t, index = math.modf(t) 84 | if index > len(self._segments) or t < 0: 85 | raise ValueError( 86 | ( 87 | f"Value of t is only valid > 0 and < {len(self._segments)} " 88 | f"got {t}." 89 | ) 90 | ) 91 | return self._segments[int(index)](t) 92 | pass 93 | -------------------------------------------------------------------------------- /pysketcher/_curve.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import numpy as np 4 | 5 | from pysketcher._angle import Angle 6 | from pysketcher._point import Point 7 | from pysketcher._shape import Shape 8 | 9 | 10 | class Curve(Shape): 11 | """General curve as a sequence of (x,y) coordintes. 12 | 13 | Examples: 14 | >>> code = ps.Curve( 15 | ... [ 16 | ... ps.Point(0, 0), 17 | ... ps.Point(1, 1), 18 | ... ps.Point(2, 4), 19 | ... ps.Point(3, 9), 20 | ... ps.Point(4, 16), 21 | ... ] 22 | ... ) 23 | >>> code.style.line_color = ps.Style.Color.BLACK 24 | >>> model = ps.Composition(dict(text=code)) 25 | >>> fig = ps.Figure(0, 5, 0, 16, backend=MatplotlibBackend) 26 | >>> fig.add(model) 27 | >>> fig.save("pysketcher/images/curve.png") 28 | 29 | 30 | .. figure:: images/curve.png 31 | :alt: An example of Curve. 32 | :figclass: align-center 33 | :scale: 30% 34 | 35 | An example of ``Curve``. 36 | """ 37 | 38 | def __init__(self, points: List[Point]): 39 | super().__init__() 40 | self._points = points 41 | # self.shapes must not be defined in this class 42 | # as self.shapes holds children objects: 43 | # Curve has no children (end leaf of self.shapes tree) 44 | 45 | @property 46 | def points(self): 47 | """The points which make up the curve.""" 48 | return self._points 49 | 50 | @property 51 | def xs(self): 52 | """The x co-ordinates of the points of the curve.""" 53 | return np.array([p.x for p in self.points]) 54 | 55 | @property 56 | def ys(self): 57 | """The y co-ordinates of the Points of the curve.""" 58 | return np.array([p.y for p in self.points]) 59 | 60 | def rotate(self, angle: Angle, center: Point) -> "Curve": 61 | """Rotate all coordinates. 62 | 63 | Args: 64 | angle: The ``Angle`` (in radians) through which the rotation should be 65 | performed. 66 | center: The ``Point`` about which the rotation should be performed. 67 | 68 | Returns: 69 | A copy of the ``Curve`` which has had all points rotated in the 70 | described manner. 71 | """ 72 | ret_curve = Curve([p.rotate(angle, center) for p in self.points]) 73 | ret_curve.style = self.style 74 | return ret_curve 75 | 76 | def scale(self, factor: float) -> "Curve": 77 | """Scale all coordinates by `factor`: ``x = factor*x``, etc.""" 78 | ret_curve = Curve( 79 | Point.from_coordinate_lists(factor * self.xs, factor * self.ys) 80 | ) 81 | ret_curve.style = self.style 82 | return ret_curve 83 | 84 | def translate(self, vec: Point) -> "Curve": 85 | """Translate all coordinates by a vector `vec`.""" 86 | ret_curve = Curve([p + vec for p in self.points]) 87 | ret_curve.style = self.style 88 | return ret_curve 89 | 90 | def __str__(self): 91 | """Compact pretty print of a Curve object.""" 92 | s = "%d (x,y) coords" % len(self._points) 93 | return s 94 | 95 | def __repr__(self): 96 | """An unambiguous string representational of a Curve object.""" 97 | return "Curve(" + ",".join([repr(p) for p in self.points]) + ")" 98 | -------------------------------------------------------------------------------- /pysketcher/_drawable.py: -------------------------------------------------------------------------------- 1 | from typing import Type, Union 2 | 3 | from pysketcher._shape import Shape 4 | from pysketcher._text import Text 5 | from pysketcher.composition import Composition 6 | 7 | Drawable: Type = Union[Shape, Text, Composition] 8 | -------------------------------------------------------------------------------- /pysketcher/_figure.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, List, Tuple, Type, Union 2 | 3 | from pysketcher._drawable import Drawable 4 | from pysketcher.backend.backend import Backend 5 | 6 | 7 | class Figure: 8 | """Provides the developer interface to the pysketcher backends. 9 | 10 | Provides the means to render models into either viewable, or 11 | savable (or both) images depending on which backend is chosen. 12 | 13 | Args: 14 | x_min: The minimum x-coordinate that will be rendered. 15 | x_max: The maximum x-coordinate that will be rendered. 16 | y_min: The minimum y-coordinate that will be rendered. 17 | y_max: The maximum y-coordinate that will be rendered. 18 | 19 | Examples: 20 | >>> circle = ps.Circle(ps.Point(1.5, 1.5), 1) 21 | >>> fig = ps.Figure(0, 3, 0, 3, backend=MatplotlibBackend) 22 | >>> fig.add(circle) 23 | >>> fig.save("pysketcher/images/figure.png") 24 | 25 | .. figure:: images/figure.png 26 | :alt: An example of the use of a Figure. 27 | :figclass: align-center 28 | 29 | An example of ``Figure``. 30 | """ 31 | 32 | _shapes: List[Drawable] 33 | _backend: Backend 34 | 35 | def __init__( 36 | self, 37 | x_min: float, 38 | x_max: float, 39 | y_min: float, 40 | y_max: float, 41 | backend: Type[Backend], 42 | ): 43 | self._backend = backend(x_min, x_max, y_min, y_max) 44 | self._shapes = [] 45 | 46 | def add(self, shape: Drawable) -> None: 47 | """Adds a shape to the Figure. 48 | 49 | Once the shape is added, when :method:``save`` or :method:``show`` is 50 | called, the portion of the shape which is within the bounds of the figure 51 | will be included in the image. 52 | 53 | Args: 54 | shape: The shape which should be added to the figure. 55 | 56 | """ 57 | self._shapes.append(shape) 58 | self._backend.add(shape) 59 | 60 | def show(self): 61 | """Shows an interactive view of the figure. 62 | 63 | If the backend supports it, then an interactive figure will be shown in a 64 | UI window. 65 | """ 66 | self._backend.show() 67 | 68 | def save(self, filename: str) -> None: 69 | """Saves the rendered figure to a file. 70 | 71 | If the backed supports it, then an image will be saved to the location 72 | specified in ``filename``. 73 | 74 | Args: 75 | filename: The location to which the figure should be saved. 76 | """ 77 | self._backend.save(filename) 78 | 79 | def erase(self): 80 | """Removes all the shapes from the figurnnnne.""" 81 | self._backend.erase() 82 | 83 | def animate( 84 | self, 85 | func: Callable[[float], Drawable], 86 | interval: Union[Tuple[float, float], Tuple[float, float, float]], 87 | ): 88 | """Renders an animation from the provided function. 89 | 90 | If the backend supports it, then an animation will be rendered 91 | and stored internally by the backend. 92 | 93 | Args: 94 | func: This function takes a parameter and provides a `Drawable` for that 95 | parameter value. 96 | interval: the start and end values for the parameter and an optional 97 | increment. If the increment is not provided, the parameter will be 98 | incremented by 1. 99 | 100 | """ 101 | self._backend.animate(func, interval) 102 | 103 | def save_animation(self, filename: str): 104 | """Saves a previously rendered animation to a file.""" 105 | self._backend.save_animation(filename) 106 | -------------------------------------------------------------------------------- /pysketcher/_force.py: -------------------------------------------------------------------------------- 1 | from pysketcher._arrow import Arrow 2 | from pysketcher._point import Point 3 | from pysketcher._style import Style 4 | from pysketcher.annotation import LineAnnotation, TextPosition 5 | from pysketcher.composition import Composition 6 | 7 | 8 | class Force(Composition): 9 | """A composition of Arrow and LinearAnnotation.""" 10 | 11 | def __init__( 12 | self, 13 | text: str, 14 | start: Point, 15 | end: Point, 16 | text_position: TextPosition = TextPosition.MIDDLE, 17 | ): 18 | self._text = text 19 | self._start = start 20 | self._end = end 21 | 22 | self._arrow = Arrow(start, end) 23 | self._label = LineAnnotation(text, self._arrow, text_position) 24 | super().__init__({"arrow": self._arrow, "label": self._label}) 25 | 26 | 27 | class Gravity(Force): 28 | """Downward-pointing gravity arrow with the symbol g.""" 29 | 30 | # TODO: add the g 31 | 32 | def __init__( 33 | self, 34 | start, 35 | length, 36 | ): 37 | Force.__init__(self, "$g$", start, start + Point(0, -length)) 38 | self.style.line_color = Style.Color.BLACK 39 | -------------------------------------------------------------------------------- /pysketcher/_line.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import Tuple 3 | 4 | from pysketcher._angle import Angle 5 | from pysketcher._curve import Curve 6 | from pysketcher._point import Point 7 | 8 | 9 | class Line(Curve): 10 | """A representation of a line primitive. 11 | 12 | Args: 13 | start: The starting point of the line. 14 | end: The end point of the line. 15 | 16 | Example: 17 | >>> a = ps.Line(ps.Point(1.0, 2.0), ps.Point(4.0, 3.0)) 18 | >>> b = a.rotate(np.pi / 2, ps.Point(1.0, 2.0)) 19 | >>> fig = ps.Figure(0, 5, 0, 5, backend=MatplotlibBackend) 20 | >>> fig.add(a) 21 | >>> fig.add(b) 22 | >>> fig.save("pysketcher/images/line.png") 23 | 24 | .. figure:: images/line.png 25 | :alt: An example of Line. 26 | :figclass: align-center 27 | 28 | An example of ``Line``. 29 | """ 30 | 31 | _start: Point 32 | _end: Point 33 | _a: float 34 | _b: float 35 | _c: float 36 | 37 | def __init__(self, start: Point, end: Point): 38 | if start == end: 39 | raise ValueError("Cannot specify a line with two equal points.") 40 | self._start = start 41 | self._end = end 42 | self._horizontal = False 43 | self._vertical = False 44 | self._a = self._b = self._c = None 45 | super().__init__([self._start, self._end]) 46 | self._compute_formulas() 47 | 48 | def _compute_formulas(self): 49 | # Define equation for line: 50 | # a * x + b * y + c = 0 51 | 52 | self._a = self._start.y - self._end.y 53 | self._b = self._end.x - self._start.x 54 | self._c = self._start.x * self._end.y - self._start.y * self._end.x 55 | 56 | @property 57 | def start(self) -> Point: 58 | """The starting point of the line.""" 59 | return self._start 60 | 61 | @property 62 | def end(self) -> Point: 63 | """The end point of the line.""" 64 | return self._end 65 | 66 | def __call__(self, x: float = None, y: float = None): 67 | """Given x, return y on the line, or given y, return x. 68 | 69 | Args: 70 | x: the value of x for which a value of y should be calculated 71 | y: the value of y for which a value of x should be calculated 72 | 73 | Returns: 74 | the appropriate value of either y or x. 75 | 76 | Raises: 77 | ValueError: If the line is horizontal and y is provided, or if 78 | the line is vertical and x is provided. 79 | """ 80 | self._compute_formulas() 81 | if self._b == 0 and y: 82 | raise ValueError("Value of x is not dependent on the value of y.") 83 | if self._a == 0 and x: 84 | raise ValueError("Value of y is not dependent on the value of x.") 85 | return ( 86 | -(self._a * x + self._c) / self._b 87 | if x 88 | else -(self._b * y + self._c) / self._a 89 | ) 90 | 91 | def interval( 92 | self, x_range: Tuple[float, float] = None, y_range: Tuple[float, float] = None 93 | ): 94 | """Returns a smaller portion of a line. 95 | 96 | Args: 97 | x_range: The range of x-coordinates which 98 | should be used to obtain the segment 99 | y_range: The range of y-coordinates which 100 | should be used to obtain the segment 101 | 102 | Returns: 103 | A line bounded to either ``x_range`` or ``y_range``. 104 | 105 | Raises: 106 | ValueError: If the line is vertical and ``x_range`` is provided, or if 107 | the line is horizontal and ``y_range`` is provided. 108 | """ 109 | if x_range and y_range: 110 | raise ValueError("Cannot specify both x_range and y_range.") 111 | if x_range is not None: 112 | return Line( 113 | Point(x_range[0], self(x_range[0])), Point(x_range[1], self(x_range[1])) 114 | ) 115 | elif y_range is not None: 116 | return Line( 117 | Point(self(y_range[0]), y_range[0]), Point(self(y_range[1]), y_range[1]) 118 | ) 119 | 120 | def rotate(self, angle: Angle, center: Point) -> "Line": 121 | """Rotate the line through an angle about a point. 122 | 123 | Args: 124 | angle: the angle in radians through which the rotation should occur. 125 | center: the point about which the rotation should occur. 126 | 127 | Returns: 128 | A copy of the line rotated through ``angle`` about ``center``. 129 | """ 130 | start = self._start.rotate(angle, center) 131 | end = self._end.rotate(angle, center) 132 | line = Line(start, end) 133 | line.style = copy(self.style) 134 | return line 135 | -------------------------------------------------------------------------------- /pysketcher/_moment.py: -------------------------------------------------------------------------------- 1 | from enum import auto, Enum, unique 2 | 3 | import numpy as np 4 | 5 | from pysketcher._angle import Angle 6 | from pysketcher._arc import Arc 7 | from pysketcher._style import Style 8 | from pysketcher.annotation import ArcAnnotation 9 | from pysketcher.composition import Composition 10 | 11 | 12 | class Moment(Composition): 13 | r"""A symbol which represents a moment. 14 | 15 | This is an ``ArcWithText`` with the ``arc_angle`` fixed at :math:`\pi`. 16 | 17 | Args: 18 | text: The text to display. 19 | center: The centre of the moment. 20 | radius: The radius of the moment. 21 | start_angle: The angle from the +ve horizontal at which the moment should 22 | start. 23 | text_spacing: The spacing of the text. 24 | resolution: The number of points on the arc. 25 | 26 | Examples: 27 | >>> moment = ps.Moment("$M$", ps.Point(0, 0), 1.0) 28 | >>> fig = ps.Figure(-1.2, 1.2, -1.2, 1.2, backend=MatplotlibBackend) 29 | >>> fig.add(moment) 30 | >>> fig.save("pysketcher/images/moment.png") 31 | 32 | .. figure:: images/moment.png 33 | :alt: An example of Moment. 34 | :figclass: align-center 35 | 36 | An example of ``Moment``. 37 | """ 38 | 39 | @unique 40 | class Direction(Enum): 41 | """Indicates the direction of the moment.""" 42 | 43 | CLOCKWISE = auto() 44 | COUNTER_CLOCKWISE = auto() 45 | 46 | @unique 47 | class Orientation(Enum): 48 | """Indicated the orientation of the moment.""" 49 | 50 | LEFT = auto() 51 | RIGHT = auto() 52 | 53 | _DEFAULT_DIRECTION: Direction = Direction.COUNTER_CLOCKWISE 54 | _DEFAULT_ORIENTATION: Orientation = Orientation.LEFT 55 | 56 | def __init__( 57 | self, 58 | text, 59 | center, 60 | radius, 61 | ): 62 | self._direction = self._DEFAULT_DIRECTION 63 | self._orientation = self._DEFAULT_ORIENTATION 64 | 65 | style = ( 66 | Style.ArrowStyle.END 67 | if self._direction == self.Direction.COUNTER_CLOCKWISE 68 | else Style.ArrowStyle.START 69 | ) 70 | 71 | start_angle = ( 72 | np.pi / 2 if self._orientation == self.Orientation.LEFT else -np.pi / 2 73 | ) 74 | 75 | self._arc = Arc(center, radius, start_angle, Angle(np.pi)).set_arrow(style) 76 | self._label = ArcAnnotation(text, self._arc) 77 | super().__init__({"arc": self._arc, "label": self._label}) 78 | -------------------------------------------------------------------------------- /pysketcher/_rectangle.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pysketcher import Angle 4 | from pysketcher._curve import Curve 5 | from pysketcher._point import Point 6 | 7 | 8 | class Rectangle(Curve): 9 | """A representation of a rectangle. 10 | 11 | Rectangle specified by the point `lower_left_corner`, `width`, 12 | and `height`. 13 | 14 | Args: 15 | lower_left_corner: The point from which the ``Rectangle`` should 16 | be drawn. 17 | width: The width of the ``Rectangle``. 18 | height: The height of the ``Rectangle``. 19 | 20 | Examples: 21 | >>> code = ps.Rectangle(ps.Point(1, 1), 3, 4) 22 | >>> model = ps.Composition(dict(text=code)) 23 | >>> fig = ps.Figure(0, 5, 0, 5, backend=MatplotlibBackend) 24 | >>> fig.add(model) 25 | >>> fig.save("pysketcher/images/rectangle.png") 26 | 27 | 28 | .. figure:: images/rectangle.png 29 | :alt: An example of Rectangle. 30 | :figclass: align-center 31 | :scale: 50% 32 | 33 | An example of ``Rectangle``. 34 | """ 35 | 36 | def __init__(self, lower_left_corner: Point, width: float, height: float): 37 | self._width = width 38 | self._height = height 39 | self._lower_left_corner = lower_left_corner 40 | 41 | super().__init__(self._generate_points()) 42 | 43 | def _generate_points(self) -> List[Point]: 44 | return [ 45 | self._lower_left_corner, 46 | self._lower_left_corner + Point(self._width, 0), 47 | self._lower_left_corner + Point(self._width, self._height), 48 | self._lower_left_corner + Point(0, self._height), 49 | self._lower_left_corner, 50 | ] 51 | 52 | def rotate(self, angle: Angle, center: Point) -> Curve: 53 | """Rotates the rectangle through ``angle`` radians about ``center``. 54 | 55 | Args: 56 | angle: The angle in radians through which the rotation should be. 57 | center: The point about which the rotation should be performed. 58 | 59 | Returns: 60 | A curve which looks like a rectangle rotated. We need to fix this 61 | so that the rectangle class supports rectangles with sides which are not 62 | parallel to the horizontal. 63 | """ 64 | points = [] 65 | for point in self._points: 66 | points.append(point.rotate(angle, center)) 67 | return Curve(points) 68 | 69 | @property 70 | def width(self) -> float: 71 | """The width of the rectangle.""" 72 | return self._width 73 | 74 | @property 75 | def height(self) -> float: 76 | """The height of the rectangle.""" 77 | return self._height 78 | 79 | @property 80 | def lower_left(self) -> Point: 81 | """The lower left point of the rectangle.""" 82 | return self._lower_left_corner 83 | 84 | @property 85 | def lower_right(self) -> Point: 86 | """The lower right point of the rectangle.""" 87 | return self._lower_left_corner + Point(self._width, 0) 88 | 89 | @property 90 | def upper_left(self) -> Point: 91 | """The upper left point of the rectangle.""" 92 | return self._lower_left_corner + Point(0, self._height) 93 | 94 | @property 95 | def upper_right(self) -> Point: 96 | """The upper right point of the rectangle.""" 97 | return self._lower_left_corner + Point(self._width, self._height) 98 | -------------------------------------------------------------------------------- /pysketcher/_shape.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from pysketcher._point import Point 4 | from pysketcher._style import Style 5 | 6 | 7 | class Stylable(ABC): 8 | """Abstract Superclass for objects which can accept a style.""" 9 | 10 | _style: Style 11 | 12 | @abstractmethod 13 | def __init__(self): 14 | self._style = Style() 15 | 16 | @property 17 | def style(self): 18 | """The style. 19 | 20 | Returns: 21 | The style associated with the class. 22 | """ 23 | return self._style 24 | 25 | @style.setter 26 | def style(self, style: Style): 27 | self._style = style 28 | 29 | def set_line_width(self, line_width: float) -> "Stylable": 30 | """Set the line width. 31 | 32 | Args: 33 | line_width: The width of the line. 34 | 35 | Returns: 36 | The object with its style modified with the new value of ``line_width``. 37 | """ 38 | self.style.line_width = line_width 39 | return self 40 | 41 | def set_line_style(self, line_style: Style.LineStyle) -> "Stylable": 42 | """Set the line style. 43 | 44 | Args: 45 | line_style: The width of the line. 46 | 47 | Returns: 48 | The object with its style modified with the new value of ``line_style``. 49 | """ 50 | self.style.line_style = line_style 51 | return self 52 | 53 | def set_line_color(self, line_color: Style.Color) -> "Stylable": 54 | """Set the line color. 55 | 56 | Args: 57 | line_color: The color of the line. 58 | 59 | Returns: 60 | The object with its style modified with the new value of ``line_color``. 61 | """ 62 | self.style.line_color = line_color 63 | return self 64 | 65 | def set_fill_pattern(self, fill_pattern: Style.FillPattern) -> "Stylable": 66 | """Set the fill_pattern style. 67 | 68 | Args: 69 | fill_pattern: The width of the line. 70 | 71 | Returns: 72 | The object with its style modified with the new value of ``line_style``. 73 | """ 74 | self.style.fill_pattern = fill_pattern 75 | return self 76 | 77 | def set_fill_color(self, fill_color: Style.Color) -> "Stylable": 78 | """Set the fill color. 79 | 80 | Args: 81 | fill_color: The width of the line. 82 | 83 | Returns: 84 | The object with its style modified with the new value of ``fill_color``. 85 | """ 86 | self.style.fill_color = fill_color 87 | return self 88 | 89 | def set_arrow(self, arrow: Style.ArrowStyle) -> "Stylable": 90 | """Set the arrows which should adorn the object. 91 | 92 | Args: 93 | arrow: The nature of the arrow. 94 | 95 | Returns: 96 | The object with its style modified with the new value of ``arrow``. 97 | """ 98 | self.style.arrow = arrow 99 | return self 100 | 101 | def set_shadow(self, shadow: float) -> "Stylable": 102 | """Set the shadow. 103 | 104 | Args: 105 | shadow: The distance of the shadow from the object. 106 | 107 | Returns: 108 | The object with its style modified with the new value of ``shadow``. 109 | """ 110 | self.style.shadow = shadow 111 | return self 112 | 113 | 114 | class Shape(Stylable): 115 | """Superclass for drawing different geometric shapes. 116 | 117 | Subclasses define shapes, but drawing, rotation, translation, 118 | etc. are done in generic functions in this superclass. 119 | """ 120 | 121 | @abstractmethod 122 | def __init__(self): 123 | super().__init__() 124 | 125 | @abstractmethod 126 | def rotate(self, angle: float, center: Point) -> "Shape": 127 | """Rotate the shape. 128 | 129 | Args: 130 | angle: The ``Angle`` in radians through which the shape should be rotated. 131 | center: The ``Point`` about which the rotation should be performed. 132 | 133 | Raises: 134 | NotImplementedError: when shape does not implement ``rotate``. 135 | """ 136 | raise NotImplementedError 137 | 138 | def translate(self, vec) -> "Shape": 139 | """Translate the shape. 140 | 141 | Args: 142 | vec: The vector through which the ``Shape`` should be translated. 143 | 144 | Raises: 145 | NotImplementedError: when shape does not implement ``translate``. 146 | """ 147 | raise NotImplementedError 148 | 149 | def scale(self, factor) -> "Shape": 150 | """Scale the shape. 151 | 152 | Args: 153 | factor: The factor by which the ``Shape`` should be scaled. 154 | 155 | Raises: 156 | NotImplementedError: when shape does not implement ``scale``. 157 | """ 158 | raise NotImplementedError 159 | -------------------------------------------------------------------------------- /pysketcher/_simple_support.py: -------------------------------------------------------------------------------- 1 | from pysketcher._point import Point 2 | from pysketcher._rectangle import Rectangle 3 | from pysketcher._triangle import Triangle 4 | from pysketcher.composition import Composition 5 | 6 | 7 | class SimpleSupport(Composition): 8 | """A representation of a simple support. 9 | 10 | Often used in static load analysis, this shows a diagrammatic 11 | representation of a point support. 12 | 13 | Args: 14 | position: The top of the simple support. 15 | size: The distance from the top of the simple support to the center of 16 | the base. 17 | 18 | Examples: 19 | >>> s = ps.SimpleSupport(ps.Point(1.0, 1.0), 0.5) 20 | >>> fig = ps.Figure(0, 2.0, 0, 1.5, backend=MatplotlibBackend) 21 | >>> fig.add(s) 22 | >>> fig.save("pysketcher/images/simple_support.png") 23 | 24 | .. figure:: images/simple_support.png 25 | :alt: An example of a SimpleSupport. 26 | :figclass: align-center 27 | 28 | An example of ``SimpleSupport``. 29 | 30 | """ 31 | 32 | _position: Point 33 | _size: float 34 | 35 | def __init__(self, position: Point, size: float): 36 | self._position = position 37 | self._size = size 38 | self._p0 = Point(position.x - size / 2.0, position.y - size) 39 | self._p1 = Point(position.x + size / 2.0, position.y - size) 40 | self._triangle = Triangle(self._p0, self._p1, position) 41 | gap = size / 5.0 42 | self._height = size / 4.0 # height of rectangle 43 | self._p2 = Point(self._p0.x, self._p0.y - gap - self._height) 44 | self._rectangle = Rectangle(self._p2, self._size, self._height) 45 | shapes = {"triangle": self._triangle, "rectangle": self._rectangle} 46 | super().__init__(shapes) 47 | 48 | @property 49 | def mid_support(self) -> Point: 50 | """Returns the midpoint of the base of the support.""" 51 | return (self._rectangle.lower_left + self._rectangle.lower_right) * 0.5 52 | -------------------------------------------------------------------------------- /pysketcher/_sketchy_func.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pysketcher._curve import Curve 4 | from pysketcher._point import Point 5 | from pysketcher._spline import Spline 6 | from pysketcher._style import Style 7 | 8 | 9 | def _scale_array(min: float, max: float, ps: np.array): 10 | return min - ps.min() + ps * (max - min) / (ps.max() - ps.min()) 11 | 12 | 13 | class SketchyFunc1(Spline): 14 | """A typical function curve used to illustrate an "arbitrary" function. 15 | 16 | Examples: 17 | >>> f = ps.SketchyFunc1() 18 | >>> fig = ps.Figure(0.0, 7.0, 0.0, 3.0, backend=MatplotlibBackend) 19 | >>> fig.add(f) 20 | >>> fig.save("pysketcher/images/sketchyfunc1.png") 21 | 22 | .. figure:: images/sketchyfunc1.png 23 | :alt: An example of an SketchyFunc1. 24 | :figclass: align-center 25 | 26 | An example of ``SketchyFunc1``. 27 | """ 28 | 29 | domain = [1, 6] 30 | 31 | def __init__(self, name=None, name_pos="start", x_min=0, x_max=6, y_min=0, y_max=2): 32 | xs = np.array([0.0, 2.0, 3.0, 4.0, 5.0, 6.0]) 33 | ys = np.array([1, 1.8, 1.2, 0.7, 0.8, 0.85]) 34 | 35 | xs = _scale_array(x_min, x_max, xs) 36 | ys = _scale_array(y_min, y_max, ys) 37 | points = Point.from_coordinate_lists(xs, ys) 38 | 39 | super().__init__(points) 40 | 41 | 42 | class SketchyFunc2(Curve): 43 | """A typical function curve used to illustrate an "arbitrary" function. 44 | 45 | Examples: 46 | >>> f = ps.SketchyFunc2() 47 | >>> fig = ps.Figure(0.0, 3.0, 0.0, 1.5, backend=MatplotlibBackend) 48 | >>> fig.add(f) 49 | >>> fig.save("pysketcher/images/sketchyfunc2.png") 50 | 51 | .. figure:: images/sketchyfunc2.png 52 | :alt: An example of an SketchyFunc1. 53 | :figclass: align-center 54 | 55 | An example of ``SketchyFunc2``. 56 | """ 57 | 58 | domain = [0, 2.25] 59 | 60 | def __init__(self, x_min=0, x_max=2.25, y_min=0.046679703125, y_max=1.259375): 61 | a = 0 62 | b = 2.25 63 | resolution = 100 64 | xs = np.linspace(a, b, resolution + 1) 65 | ys = self.__call__(xs) 66 | # Scale x and y 67 | xs = _scale_array(x_min, x_max, xs) 68 | ys = _scale_array(y_min, y_max, ys) 69 | points = Point.from_coordinate_lists(xs, ys) 70 | 71 | super().__init__(points) 72 | 73 | def __call__(self, x): 74 | """Returns the value of the function at a given :math:`x`.""" 75 | return 0.5 + x * (2 - x) * (0.9 - x) # on [0, 2.25] 76 | 77 | 78 | class SketchyFunc3(Spline): 79 | """A typical function curve used to illustrate an "arbitrary" function. 80 | 81 | Examples: 82 | >>> f = ps.SketchyFunc3() 83 | >>> fig = ps.Figure(0.0, 7.0, 0.0, 4.5, backend=MatplotlibBackend) 84 | >>> fig.add(f) 85 | >>> fig.save("pysketcher/images/sketchyfunc3.png") 86 | 87 | .. figure:: images/sketchyfunc3.png 88 | :alt: An example of an SketchyFunc3. 89 | :figclass: align-center 90 | 91 | An example of ``SketchyFunc3``. 92 | """ 93 | 94 | domain = [0, 6] 95 | 96 | def __init__(self, x_min=0, x_max=6, y_min=0.5, y_max=3.8): 97 | xs = np.array([0.0, 2.0, 3.0, 4.0, 5.0, 6.0]) 98 | ys = np.array([0.5, 3.5, 3.8, 2, 2.5, 3.5]) 99 | 100 | # Scale x and y 101 | xs = _scale_array(x_min, x_max, xs) 102 | ys = _scale_array(y_min, y_max, ys) 103 | points = Point.from_coordinate_lists(xs, ys) 104 | 105 | super().__init__(points) 106 | self.style.line_color = Style.Color.BLACK 107 | 108 | 109 | class SketchyFunc4(Spline): 110 | """A typical function curve used to illustrate an "arbitrary" function. 111 | 112 | Can be a companion function to SketchyFunc3. 113 | 114 | Examples: 115 | >>> f = ps.SketchyFunc4() 116 | >>> fig = ps.Figure(0.0, 7.0, 0.0, 3.0, backend=MatplotlibBackend) 117 | >>> fig.add(f) 118 | >>> fig.save("pysketcher/images/sketchyfunc4.png") 119 | 120 | .. figure:: images/sketchyfunc4.png 121 | :alt: An example of an SketchyFunc4. 122 | :figclass: align-center 123 | 124 | An example of ``SketchyFunc4``. 125 | """ 126 | 127 | domain = [1, 6] 128 | 129 | def __init__(self, name_pos="start", x_min=0, x_max=6, y_min=0.5, y_max=1.8): 130 | xs = np.array([0, 2, 3, 4, 5, 6]) 131 | ys = np.array([1.5, 1.3, 0.7, 0.5, 0.6, 0.8]) 132 | # Scale x and y 133 | xs = _scale_array(x_min, x_max, xs) 134 | ys = _scale_array(y_min, y_max, ys) 135 | points = Point.from_coordinate_lists(xs, ys) 136 | 137 | super().__init__(points) 138 | -------------------------------------------------------------------------------- /pysketcher/_spline.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import numpy as np 4 | from scipy.interpolate import UnivariateSpline 5 | 6 | from pysketcher._curve import Curve 7 | from pysketcher._point import Point 8 | 9 | 10 | class Spline(Curve): 11 | """A univariate spline. 12 | 13 | Note: UnivariateSpline interpolation may not work if 14 | the x[i] points are far from uniformly spaced. 15 | 16 | Examples: 17 | >>> s = ps.Spline( 18 | ... [ 19 | ... ps.Point(0, 0), 20 | ... ps.Point(1, 1), 21 | ... ps.Point(2, 4), 22 | ... ps.Point(3, 9), 23 | ... ps.Point(4, 16), 24 | ... ] 25 | ... ) 26 | >>> fig = ps.Figure(0, 5, 0, 16, backend=MatplotlibBackend) 27 | >>> fig.add(s) 28 | >>> fig.save("pysketcher/images/spline.png") 29 | 30 | .. figure:: images/spline.png 31 | :alt: An example of a Spline. 32 | :figclass: align-center 33 | :scale: 30% 34 | 35 | An example of ``Spline``. 36 | """ 37 | 38 | _input_points: List[Point] 39 | _smooth: UnivariateSpline 40 | 41 | def __init__(self, points: List[Point], degree: int = 3, resolution: int = 501): 42 | self._input_points = points 43 | self._smooth = UnivariateSpline( 44 | [p.x for p in points], [p.y for p in points], s=0, k=degree 45 | ) 46 | x_coordinates = np.linspace(points[0].x, points[-1].x, resolution) 47 | y_coordinates = self._smooth(x_coordinates) 48 | smooth_points = [Point(p[0], p[1]) for p in zip(x_coordinates, y_coordinates)] 49 | super().__init__(smooth_points) 50 | 51 | def __call__(self, x): 52 | """Returns the value of the curve at a given x-coordinate.""" 53 | return self._smooth(x) 54 | -------------------------------------------------------------------------------- /pysketcher/_spring.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pysketcher import Curve, Point 4 | from pysketcher._line import Line 5 | from pysketcher.composition import Composition 6 | 7 | 8 | class Spring(Composition): 9 | """A representation of a spring. 10 | 11 | Specify a *vertical* spring, starting at `start` and with `length` 12 | as total vertical length. In the middle of the spring there are 13 | `num_windings` circular windings to illustrate the spring. If 14 | `teeth` is true, the spring windings look like saw teeth, 15 | otherwise the windings are smooth circles. The parameters `width` 16 | (total width of spring) and `bar_length` (length of first and last 17 | bar are given sensible default values if they are not specified 18 | (these parameters can later be extracted as attributes, see table 19 | below). 20 | 21 | Examples: 22 | >>> L = 12.0 23 | >>> H = L / 6.0 24 | >>> start = ps.Point(0.0, 0.0) 25 | >>> s = ps.Spring(start, L) 26 | >>> fig = ps.Figure(-2, 2, -1, L + H, backend=MatplotlibBackend) 27 | >>> fig.add(s) 28 | >>> fig.save("pysketcher/images/spring.png") 29 | 30 | .. figure:: images/spring.png 31 | :alt: An example of a Spring. 32 | :figclass: align-center 33 | :scale: 30% 34 | 35 | An example of a ``Spring``. 36 | """ 37 | 38 | spring_fraction = 1.0 / 2 # fraction of total length occupied by spring 39 | 40 | def __init__( 41 | self, 42 | start: Point, 43 | length: float, 44 | width: float = None, 45 | bar_length: float = None, 46 | num_windings: int = 11, 47 | teeth: bool = False, 48 | ): 49 | B = start 50 | n = num_windings - 1 # n counts teeth intervals 51 | if n <= 6: 52 | n = 7 53 | # n must be odd: 54 | if n % 2 == 0: 55 | n = n + 1 56 | L = length 57 | if width is None: 58 | w = L / 10.0 59 | else: 60 | w = width / 2.0 61 | s = bar_length 62 | 63 | shapes = {} 64 | if s is None: 65 | f = Spring.spring_fraction 66 | s = L * (1 - f) / 2.0 # start of spring 67 | 68 | self.bar_length = s # record 69 | self.width = 2 * w 70 | 71 | p0 = Point(B.x, B.y + s) 72 | p1 = Point(B.x, B.y + L - s) 73 | p2 = Point(B.x, B.y + L) 74 | 75 | if s >= L: 76 | raise ValueError( 77 | "length of first bar: %g is larger than total length: %g" % (s, L) 78 | ) 79 | 80 | shapes["bar1"] = Line(B, p0) 81 | spring_length = L - 2 * s 82 | t = spring_length / n # height increment per winding 83 | if teeth: 84 | resolution = 4 85 | else: 86 | resolution = 90 87 | q = np.linspace(0, n, n * resolution + 1) 88 | xs = p0.x + w * np.sin(2 * np.pi * q) 89 | ys = p0.y + q * t 90 | points = Point.from_coordinate_lists(xs, ys) 91 | shapes["spiral"] = Curve(points) 92 | shapes["bar2"] = Line(p1, p2) 93 | super().__init__(shapes) 94 | -------------------------------------------------------------------------------- /pysketcher/_text.py: -------------------------------------------------------------------------------- 1 | from pysketcher._angle import Angle 2 | from pysketcher._point import Point 3 | from pysketcher._shape import Shape 4 | from pysketcher._style import TextStyle 5 | 6 | 7 | class Text(Shape): 8 | """Place `text` on the drawing at the Point(x, y) `position`. 9 | 10 | The `text` will be drawn in the given `direction` 11 | 12 | Args: 13 | text: The text to be displayed. 14 | position: Point, The position the text will be displayed at. 15 | direction: Point, The direction the text will flow to. 16 | 17 | Examples: 18 | >>> fig = ps.Figure(0.0, 4.0, 0.0, 4.0, MatplotlibBackend) 19 | >>> code = ps.Text("This is some left text!", Point(2, 1)) 20 | >>> code.style.alignment = ps.TextStyle.Alignment.LEFT 21 | >>> code.style.line_color = ps.TextStyle.Color.BLUE 22 | >>> code.style.font_family = ps.TextStyle.FontFamily.SERIF 23 | >>> code1 = ps.Text("This is some right text!", Point(2, 2)) 24 | >>> code1.style.alignment = ps.TextStyle.Alignment.RIGHT 25 | >>> code1.style.line_color = ps.TextStyle.Color.GREEN 26 | >>> code1.style.font_family = ps.TextStyle.FontFamily.SANS 27 | >>> code2 = ps.Text("This is some center text!", Point(2, 3)) 28 | >>> code2.style.alignment = ps.TextStyle.Alignment.CENTER 29 | >>> code2.style.line_color = ps.TextStyle.Color.RED 30 | >>> code2.style.font_family = ps.TextStyle.FontFamily.MONO 31 | >>> fig.add(code) 32 | >>> fig.add(code1) 33 | >>> fig.add(code2) 34 | >>> fig.save("pysketcher/images/text.png") 35 | 36 | .. figure:: images/text.png 37 | :alt: An example of some text. 38 | :figclass: align-center 39 | 40 | An example of some ``Text``. 41 | """ 42 | 43 | _style: TextStyle 44 | 45 | def __init__( 46 | self, text: str, position: Point, direction: Point = Point(1, 0) # noqa: B008 47 | ): 48 | super().__init__() 49 | self._text: str = text 50 | self._position: Point = position 51 | self._direction: Point = direction 52 | self._style: TextStyle = TextStyle() 53 | 54 | def rotate(self, angle: Angle, center: Point) -> "Text": 55 | """Returns the text rotated through ``angle`` radians about ``centre``.""" 56 | direction = self._direction.rotate(angle, center) 57 | position = self._position.rotate(angle, center) 58 | return Text(self._text, position, direction) 59 | 60 | def __str__(self): 61 | """Provides a string which describes the Text object.""" 62 | return 'text "%s" at (%g,%g)' % (self._text, self._position.x, self._position.y) 63 | 64 | def __repr__(self): 65 | """Provides a string which describes the Text object.""" 66 | return repr(str(self)) 67 | 68 | @property 69 | def style(self) -> TextStyle: 70 | """Returns the style of the object so that the style can be altered.""" 71 | return self._style 72 | 73 | @style.setter 74 | def style(self, text_style: TextStyle): 75 | self._style = text_style 76 | 77 | @property 78 | def position(self) -> Point: 79 | """The ``Point`` at which the text is located.""" 80 | return self._position 81 | 82 | @property 83 | def direction(self) -> Point: 84 | """The direction in which the text flows.""" 85 | return self._direction 86 | 87 | @property 88 | def text(self) -> str: 89 | """The text.""" 90 | return self._text 91 | 92 | def set_alignment(self, alignment: TextStyle.Alignment) -> "Text": 93 | """Sets the alignment of the text. 94 | 95 | Args: 96 | alignment: The new alignment of the text. 97 | 98 | Returns: 99 | The original text object with the style modified to the new alignment. 100 | """ 101 | self.style.alignment = alignment 102 | return self 103 | 104 | def translate(self, vec: Point) -> "Text": 105 | """Translates the text through ``vec``.""" 106 | new_text = Text(self.text, self.position + vec, self.direction) 107 | new_text.style = self._style 108 | return new_text 109 | 110 | def scale(self, factor: float) -> "Text": 111 | """Scales the text by a factor of `factor`.""" 112 | raise NotImplementedError 113 | -------------------------------------------------------------------------------- /pysketcher/_triangle.py: -------------------------------------------------------------------------------- 1 | from pysketcher._curve import Curve 2 | from pysketcher._point import Point 3 | 4 | 5 | class Triangle(Curve): 6 | """Triangle defined by its three vertices p1, p2, and p3. 7 | 8 | Args: 9 | p1: The first ``Point``. 10 | p2: The second ``Point``. 11 | p3: The third ``Point``. 12 | 13 | Examples: 14 | >>> model = ps.Triangle(ps.Point(1, 1), ps.Point(1, 4), ps.Point(3, 3)) 15 | >>> fig = ps.Figure(0, 5, 0, 5, backend=MatplotlibBackend) 16 | >>> fig.add(model) 17 | >>> fig.save("pysketcher/images/triangle.png") 18 | 19 | .. figure:: images/triangle.png 20 | :alt: An example of Triangle. 21 | :figclass: align-center 22 | :scale: 30% 23 | 24 | An example of ``Triangle``. 25 | """ 26 | 27 | def __init__(self, p1: Point, p2: Point, p3: Point): 28 | self._p1 = p1 29 | self._p2 = p2 30 | self._p3 = p3 31 | 32 | super().__init__([p1, p2, p3, p1]) 33 | 34 | def rotate(self, angle: float, center: Point): 35 | """Rotates the Triangle through a ``angle`` about ``center``. 36 | 37 | Args: 38 | angle: The angle through which the triangle should be rotated in radians. 39 | center: The point about which the triangle should be rotated. 40 | 41 | Returns: 42 | A copy of the triangle subjected to the specified rotation. 43 | """ 44 | return Triangle( 45 | self._p1.rotate(angle, center), 46 | self._p2.rotate(angle, center), 47 | self._p3.rotate(angle, center), 48 | ) 49 | -------------------------------------------------------------------------------- /pysketcher/_uniform_load.py: -------------------------------------------------------------------------------- 1 | from pysketcher._arrow import Arrow 2 | from pysketcher._point import Point 3 | from pysketcher._rectangle import Rectangle 4 | from pysketcher.composition import Composition 5 | 6 | 7 | class UniformLoad(Composition): 8 | """Downward-pointing arrows indicating a vertical load. 9 | 10 | The arrows are of equal length and filling a rectangle 11 | specified as in the :class:`Rectangle` class. 12 | 13 | Examples: 14 | >>> l = ps.UniformLoad(ps.Point(0.5, 0.5), 4, 0.5) 15 | >>> fig = ps.Figure(0.0, 5.0, 0.0, 1, backend=MatplotlibBackend) 16 | >>> fig.add(l) 17 | >>> fig.save("pysketcher/images/uniform_load.png") 18 | 19 | .. figure:: images/uniform_load.png 20 | :alt: An example of a Uniform Load. 21 | :figclass: align-center 22 | 23 | An example of a ``UniformLoad``. 24 | """ 25 | 26 | def __init__( 27 | self, lower_left_corner: Point, width: float, height: float, num_arrows=10 28 | ): 29 | self._lower_left_corner = lower_left_corner 30 | self._width = width 31 | self._height = height 32 | box = Rectangle(lower_left_corner, width, height) 33 | shapes = {"box": box} 34 | dx = float(width) / (num_arrows - 1) 35 | for i in range(num_arrows): 36 | x = lower_left_corner.x + i * dx 37 | start = Point(x, lower_left_corner.y + height) 38 | end = Point(x, lower_left_corner.y) 39 | shapes["arrow%d" % i] = Arrow(start, end) 40 | super().__init__(shapes) 41 | 42 | @property 43 | def mid_top(self) -> Point: 44 | """The middle of the top of the load.""" 45 | return self._lower_left_corner + Point(self._width / 2, self._height) 46 | -------------------------------------------------------------------------------- /pysketcher/_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from pysketcher._utils.doc_enum import DocEnum 2 | 3 | __all__ = ["DocEnum"] 4 | -------------------------------------------------------------------------------- /pysketcher/_utils/doc_enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Any 3 | 4 | 5 | class DocEnum(Enum): 6 | """Provides a handy way to document Enums.""" 7 | 8 | def __new__(cls, value: Any, doc: str = None): 9 | """Creates an instance of a DocEnum. 10 | 11 | Args: 12 | value: The enumerate value as would be included in a normal enum. 13 | doc: A documentation string for the value. 14 | 15 | Returns: 16 | An instance of the DocEnum class. 17 | """ 18 | self = object.__new__(cls) # calling super().__new__(value) here would fail 19 | self._value_ = value 20 | if doc is not None: 21 | self.__doc__ = doc 22 | return self 23 | -------------------------------------------------------------------------------- /pysketcher/_velocity_profile.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from pysketcher._arrow import Arrow 4 | from pysketcher._line import Line 5 | from pysketcher._point import Point 6 | from pysketcher._spline import Spline 7 | from pysketcher.composition import Composition 8 | 9 | 10 | class VelocityProfile(Composition): 11 | """A representation of the profile of velocity in laminar flow. 12 | 13 | Args: 14 | start: the point from which the profile should start. 15 | height: the height of the profile. 16 | profile: a function which provides the value of the profile at a given point 17 | num_arrows: the number of arrows to display 18 | scaling: a scaling factor 19 | 20 | Examples: 21 | >>> def velocity_profile(y: float) -> ps.Point: 22 | ... return ps.Point(y * (8 - y) / 4, 0) 23 | >>> pr = ps.VelocityProfile(ps.Point(0, 0), 4, velocity_profile, 5) 24 | >>> fig = ps.Figure(0, 4.1, 0, 4, backend=MatplotlibBackend) 25 | >>> fig.add(pr) 26 | >>> fig.save("pysketcher/images/velocity_profile.png") 27 | 28 | .. figure:: images/velocity_profile.png 29 | :alt: An example of a Velocity Profile. 30 | :figclass: align-center 31 | :scale: 50% 32 | 33 | An example of ``VelocityProfile``. 34 | """ 35 | 36 | _start: Point 37 | _height: float 38 | _profile: Callable[[float], Point] 39 | _num_arrows: int 40 | _scaling: float 41 | 42 | def __init__( 43 | self, 44 | start: Point, 45 | height: float, 46 | profile: Callable[[float], Point], 47 | num_arrows: int, 48 | scaling: float = 1, 49 | ): 50 | self._start = start 51 | self._height = height 52 | self._profile = profile 53 | self._num_arrows = num_arrows 54 | self._scaling = scaling 55 | 56 | shapes = dict() 57 | 58 | # Draw left line 59 | shapes["start line"] = Line(self._start, (self._start + Point(0, self._height))) 60 | 61 | # Draw velocity arrows 62 | dy = float(self._height) / (self._num_arrows - 1) 63 | 64 | end_points = [] 65 | 66 | for i in range(self._num_arrows): 67 | start_position = Point(start.x, start.y + i * dy) 68 | end_position = start_position + profile(start_position.y) * self._scaling 69 | end_points += [end_position] 70 | if start_position == end_position: 71 | continue 72 | shapes["arrow%d" % i] = Arrow(start_position, end_position) 73 | 74 | shapes["smooth curve"] = Spline(end_points) 75 | super().__init__(shapes) 76 | -------------------------------------------------------------------------------- /pysketcher/_wall.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from pysketcher._curve import Curve 4 | from pysketcher._point import Point 5 | from pysketcher._style import Style 6 | 7 | 8 | class Wall(Curve): 9 | """A representation of a wall. 10 | 11 | Args: 12 | points: a ``List`` of ``Point`` through which the wall should pass. 13 | thickness: the thickness of the wall. 14 | 15 | Examples: 16 | >>> model = ps.Wall( 17 | ... [ 18 | ... ps.Point(1, 1), 19 | ... ps.Point(2, 2), 20 | ... ps.Point(3, 2.5), 21 | ... ps.Point(4, 2), 22 | ... ps.Point(5, 1), 23 | ... ], 24 | ... 0.1, 25 | ... ) 26 | >>> fig = ps.Figure(0, 6, 0, 3, backend=MatplotlibBackend) 27 | >>> fig.add(model) 28 | >>> fig.save("pysketcher/images/wall.png") 29 | 30 | .. figure:: images/wall.png 31 | :alt: An example of a Wall. 32 | :figclass: align-center 33 | 34 | An example ``Wall``. 35 | """ 36 | 37 | _start: Point 38 | _end: Point 39 | _thickness: float 40 | 41 | def __init__(self, points: List[Point], thickness: float): 42 | self._start = points[0] 43 | self._end = points[-1] 44 | self._thickness = thickness 45 | 46 | def _displace(point: Point, point_before: Point, point_after: Point): 47 | # Displaces a point on our curve by thickness. 48 | # find a normal to the line between the point_before and the point_after 49 | # then displace by thickness from point in the direction of that normal 50 | return point + ((point_after - point_before).normal * self._thickness) 51 | 52 | # at the start, there isn't a point_before, so use the start point 53 | new_points: List[Point] = [_displace(points[0], points[0], points[1])] 54 | 55 | for i in range(1, len(points) - 1): 56 | new_points += [_displace(points[i], points[i - 1], points[i + 1])] 57 | 58 | # and at the end there isn't a point_after, so use the end point 59 | new_points += [_displace(points[-1], points[-2], points[-1])] 60 | 61 | points = points + new_points[-1::-1] 62 | points += [self._start] 63 | 64 | super().__init__(points) 65 | self.style.fill_pattern = Style.FillPattern.CROSS 66 | -------------------------------------------------------------------------------- /pysketcher/_warning.py: -------------------------------------------------------------------------------- 1 | class LossOfPrecisionWarning(UserWarning): 2 | """Thrown when circumstances are such that precision may be lost.""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /pysketcher/_wheel.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from pysketcher._circle import Circle 4 | from pysketcher._line import Line 5 | from pysketcher._point import Point 6 | from pysketcher.composition import Composition 7 | 8 | 9 | class Wheel(Composition): 10 | """A representation of a wheel. 11 | 12 | Args: 13 | center: The center of the ``Wheel``. 14 | radius: The radius of the ``Wheel``. 15 | inner_radius: The radius of the hub of the ``Wheel``. 16 | 17 | Examples: 18 | >>> w = ps.Wheel(ps.Point(1.0, 1.0), 0.5, 0.25) 19 | >>> fig = ps.Figure(0.0, 2.0, 0.0, 2.0, backend=MatplotlibBackend) 20 | >>> fig.add(w) 21 | >>> fig.save("pysketcher/images/wheel.png") 22 | 23 | .. figure:: images/wheel.png 24 | :alt: An example of a Wheel. 25 | :figclass: align-center 26 | 27 | An example of a ``Wheel``. 28 | """ 29 | 30 | _center: Point 31 | _radius: float 32 | _inner_radius: float 33 | _nlines: int 34 | 35 | def __init__( 36 | self, center: Point, radius: float, inner_radius: float = None, nlines: int = 10 37 | ): 38 | self._center = center 39 | self._radius = radius 40 | self._inner_radius = inner_radius 41 | self._nlines = nlines 42 | 43 | if inner_radius is None: 44 | self._inner_radius = radius / 5.0 45 | 46 | outer = Circle(center, radius) 47 | inner = Circle(center, inner_radius) 48 | lines = [] 49 | # Draw nlines+1 since the first and last coincide 50 | # (then nlines lines will be visible) 51 | t = np.linspace(0, 2 * np.pi, nlines + 1) 52 | 53 | xinner = self._center.x + self._inner_radius * np.cos(t) 54 | yinner = self._center.y + self._inner_radius * np.sin(t) 55 | xouter = self._center.x + self._radius * np.cos(t) 56 | youter = self._center.y + self._radius * np.sin(t) 57 | lines = [ 58 | Line(Point(xi, yi), Point(xo, yo)) 59 | for xi, yi, xo, yo in zip(xinner, yinner, xouter, youter) 60 | ] 61 | super().__init__( 62 | { 63 | "inner": inner, 64 | "outer": outer, 65 | "spokes": Composition( 66 | {"spoke%d" % i: lines[i] for i in range(len(lines))} 67 | ), 68 | } 69 | ) 70 | -------------------------------------------------------------------------------- /pysketcher/annotation/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides a means to annotate shapes. 2 | 3 | Annotations are text objects which are positioned using the dimensions 4 | of a provide shape. 5 | """ 6 | 7 | from pysketcher.annotation._arc_annotation import ArcAnnotation 8 | from pysketcher.annotation._line_annotation import LineAnnotation 9 | from pysketcher.annotation._text_position import TextPosition 10 | 11 | __all__ = ["ArcAnnotation", "LineAnnotation", "TextPosition"] 12 | -------------------------------------------------------------------------------- /pysketcher/annotation/_arc_annotation.py: -------------------------------------------------------------------------------- 1 | from pysketcher._arc import Arc 2 | from pysketcher._text import Text 3 | from pysketcher.annotation._text_position import TextPosition 4 | 5 | 6 | class ArcAnnotation(Text): 7 | """Annotates an arc with the provided text.""" 8 | 9 | # TODO: write an example for ArcAnnotation 10 | _DEFAULT_OFFSET = 0.25 11 | 12 | def __init__( 13 | self, text: str, arc: Arc, text_position: TextPosition = TextPosition.MIDDLE 14 | ): 15 | self._arc = arc 16 | self._offset = self._DEFAULT_OFFSET 17 | radial = (arc.center - arc.mid).unit_vector 18 | text_pos = arc.mid - (radial * self._offset) 19 | super().__init__(text, text_pos) 20 | -------------------------------------------------------------------------------- /pysketcher/annotation/_line_annotation.py: -------------------------------------------------------------------------------- 1 | from pysketcher._line import Line 2 | from pysketcher._point import Point 3 | from pysketcher._style import TextStyle 4 | from pysketcher._text import Text 5 | from pysketcher.annotation._text_position import TextPosition 6 | 7 | 8 | class LineAnnotation(Text): 9 | """Annotates a line with the provided text.""" 10 | 11 | # TODO: Write a LineAnnotation Example 12 | _DEFAULT_SPACING: Point = Point(0.15, 0.15) 13 | 14 | def __init__( 15 | self, text: str, line: Line, text_position: TextPosition = TextPosition.MIDDLE 16 | ): 17 | spacing = self._DEFAULT_SPACING 18 | 19 | if text_position == TextPosition.START: 20 | position = line.start + spacing 21 | alignment = TextStyle.Alignment.LEFT 22 | elif text_position == TextPosition.END: 23 | position = line.end + spacing 24 | alignment = TextStyle.Alignment.RIGHT 25 | elif text_position == TextPosition.MIDDLE: 26 | position = line.start + (line.end - line.start) * 0.5 + spacing 27 | alignment = TextStyle.Alignment.CENTER 28 | else: 29 | raise RuntimeError(f"Invalid value of text_position: {text_position}.") 30 | 31 | super().__init__(text, position) 32 | self.style.alignment = alignment 33 | -------------------------------------------------------------------------------- /pysketcher/annotation/_text_position.py: -------------------------------------------------------------------------------- 1 | from enum import auto, Enum, unique 2 | 3 | 4 | @unique 5 | class TextPosition(Enum): 6 | """Specifies the position of text in Annotations.""" 7 | 8 | START = auto() 9 | MIDDLE = auto() 10 | END = auto() 11 | -------------------------------------------------------------------------------- /pysketcher/backend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/pysketcher/backend/__init__.py -------------------------------------------------------------------------------- /pysketcher/backend/backend.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Callable, Tuple, Union 3 | 4 | from pysketcher._drawable import Drawable 5 | 6 | 7 | class Backend(ABC): 8 | pass 9 | 10 | @abstractmethod 11 | def add(self, shape: Drawable) -> None: 12 | pass 13 | 14 | @abstractmethod 15 | def erase(self) -> None: 16 | pass 17 | 18 | @abstractmethod 19 | def show(self) -> None: 20 | pass 21 | 22 | @abstractmethod 23 | def save(self, filename: str) -> None: 24 | pass 25 | 26 | def animate( 27 | self, 28 | func: Callable[[float], Drawable], 29 | interval: Union[Tuple[float, float], Tuple[float, float, float]], 30 | ): 31 | raise NotImplementedError("This backend doesn't implement animation.") 32 | 33 | def save_animation(self, filename: str): 34 | raise NotImplementedError("This backend doesn't implement animation.") 35 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/__init__.py: -------------------------------------------------------------------------------- 1 | from pysketcher.backend.matplotlib._matplotlib_backend import MatplotlibBackend 2 | 3 | __all__ = [MatplotlibBackend] 4 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_adapter.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from matplotlib.axes import Axes 4 | 5 | from pysketcher._drawable import Drawable 6 | 7 | 8 | class MatplotlibAdapter(ABC): 9 | @staticmethod 10 | @abstractmethod 11 | def plot(shape: Drawable, axes: Axes): 12 | pass 13 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_backend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, Dict, Optional, Tuple, Type, Union 3 | 4 | from celluloid import Camera 5 | from matplotlib.animation import ArtistAnimation 6 | import matplotlib.pyplot as plt 7 | 8 | from pysketcher._curve import Curve 9 | from pysketcher._drawable import Drawable 10 | from pysketcher._text import Text 11 | from pysketcher.backend.backend import Backend 12 | from pysketcher.backend.matplotlib._matplotlib_adapter import MatplotlibAdapter 13 | from pysketcher.backend.matplotlib._matplotlib_composition import MatplotlibComposition 14 | from pysketcher.backend.matplotlib._matplotlib_curve import MatplotlibCurve 15 | from pysketcher.backend.matplotlib._matplotlib_text import MatplotlibText 16 | from pysketcher.composition import Composition 17 | 18 | 19 | # plt.rc("text", usetex=True) 20 | # plt.rcParams["text.latex.preamble"] = r"\usepackage{amsmath}" 21 | 22 | 23 | class MatplotlibBackend(Backend): 24 | """Simple interface for plotting. Makes use of Matplotlib for plotting.""" 25 | 26 | _fig: plt.Figure 27 | _axes: plt.Axes 28 | _camera: Optional[Camera] 29 | _x_min: float 30 | _y_min: float 31 | _x_max: float 32 | _y_max: float 33 | 34 | _INTERVAL: int = 40 # ms between each frame 35 | 36 | def __init__(self, x_min, x_max, y_min, y_max): 37 | plt.ion() 38 | self._x_min = x_min 39 | self._x_max = x_max 40 | self._y_min = y_min 41 | self._y_max = y_max 42 | self._camera = None 43 | self._fig = plt.figure( 44 | figsize=[(x_max - x_min) * 3, (y_max - y_min) * 3], tight_layout=False 45 | ) 46 | self._axes = self._fig.gca() 47 | self._configure_axes() 48 | 49 | def _configure_axes(self): 50 | self._axes.set_xlim(self._x_min, self._x_max) 51 | self._axes.set_ylim(self._y_min, self._y_max) 52 | self._axes.set_aspect("equal") 53 | self._axes.set_axis_off() 54 | 55 | def add(self, shape: Drawable) -> None: 56 | for typ, adapter in self._adapters.items(): 57 | if issubclass(shape.__class__, typ): 58 | adapter.plot(shape, self._axes) 59 | 60 | def erase(self): 61 | self._fig.clear() 62 | self._axes = self._fig.gca() 63 | self._configure_axes() 64 | 65 | def show(self): 66 | self._fig.sca(self._axes) 67 | self._fig.canvas.draw() 68 | self._fig.show() 69 | 70 | def save(self, filename: str) -> None: 71 | logging.info(f"Saving to {filename}.") 72 | # TODO: manage formats 73 | self._fig.savefig(filename) 74 | 75 | @property 76 | def _adapters(self) -> Dict[Type, MatplotlibAdapter]: 77 | return { 78 | Curve: MatplotlibCurve(), 79 | Text: MatplotlibText(), 80 | Composition: MatplotlibComposition(self), 81 | } 82 | 83 | def animate( 84 | self, 85 | func: Callable[[float], Drawable], 86 | interval: Union[Tuple[float, float], Tuple[float, float, float]], 87 | ): 88 | if len(interval) == 2: 89 | start, end = interval 90 | increment = 1 91 | else: 92 | start, end, increment = interval 93 | self._camera = Camera(self._fig) 94 | i: float = start 95 | while i < end: 96 | self.add(func(i)) 97 | self._camera.snap() 98 | i += increment 99 | 100 | def show_animation(self): 101 | animation: ArtistAnimation = self._camera.animate(interval=self._INTERVAL) 102 | animation.show() 103 | 104 | def save_animation(self, filename: str): 105 | animation: ArtistAnimation = self._camera.animate(interval=self._INTERVAL) 106 | animation.save(filename) 107 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_composition.py: -------------------------------------------------------------------------------- 1 | from matplotlib.axes import Axes 2 | 3 | from pysketcher.backend.matplotlib._matplotlib_adapter import MatplotlibAdapter 4 | from pysketcher.composition import Composition 5 | 6 | 7 | class MatplotlibComposition(MatplotlibAdapter): 8 | def __init__(self, mplb): 9 | self._mplb = mplb 10 | 11 | def plot(self, shape: Composition, axes: Axes): 12 | shape.apply(self._mplb.add) 13 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_curve.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | from typing import List 3 | 4 | import matplotlib.pyplot as plt 5 | import numpy as np 6 | 7 | import pysketcher as ps 8 | from pysketcher._style import Style 9 | from pysketcher.backend.matplotlib._matplotlib_adapter import MatplotlibAdapter 10 | from pysketcher.backend.matplotlib._matplotlib_style import MatplotlibStyle 11 | 12 | 13 | class MatplotlibCurve(MatplotlibAdapter): 14 | types: List[type] = [ps.Curve] 15 | 16 | def plot(self, curve: ps.Curve, axes: plt.Axes) -> None: 17 | """Draw a curve with coordinates x and y (arrays).""" 18 | mpl_style = MatplotlibStyle(curve.style) 19 | 20 | xs = curve.xs 21 | ys = curve.ys 22 | 23 | if len(xs) == 2 and curve.style.arrow == Style.ArrowStyle.DOUBLE: 24 | # For multi-segment curves we can just make the first segment an 25 | # arrow if the style is START or DOUBLE and the last segment an 26 | # arrow if the style is END or DOUBLE. If there is only one segment 27 | # and the style is DOUBLE this won't work, so we split into two segments 28 | xs = np.linspace(xs[0], xs[1], 3) 29 | ys = np.linspace(ys[0], ys[1], 3) 30 | 31 | if curve.style.arrow is not None: 32 | if curve.style.arrow in [Style.ArrowStyle.START, Style.ArrowStyle.DOUBLE]: 33 | x_s, y_s = xs[0], ys[0] 34 | dx_s, dy_s = xs[1] - xs[0], ys[1] - ys[0] 35 | start_style = copy(curve.style) 36 | start_style.arrow = Style.ArrowStyle.END 37 | self._plot_arrow(x_s, y_s, dx_s, dy_s, start_style, axes) 38 | xs = xs[1:] 39 | ys = ys[1:] 40 | if curve.style.arrow in [Style.ArrowStyle.END, Style.ArrowStyle.DOUBLE]: 41 | x_e, y_e = xs[-2], ys[-2] 42 | dx_e, dy_e = xs[-1] - xs[-2], ys[-1] - ys[-2] 43 | end_style = copy(curve.style) 44 | end_style.arrow = Style.ArrowStyle.START 45 | self._plot_arrow(x_e, y_e, dx_e, dy_e, end_style, axes) 46 | xs = xs[:-1] 47 | ys = ys[:-1] 48 | 49 | if len(xs) >= 2: 50 | [line] = axes.plot( 51 | xs, 52 | ys, 53 | mpl_style.line_color, 54 | linewidth=mpl_style.line_width, 55 | linestyle=mpl_style.line_style, 56 | ) 57 | if mpl_style.fill_color or mpl_style.fill_pattern: 58 | [line] = plt.fill( 59 | xs, 60 | ys, 61 | mpl_style.fill_color, 62 | edgecolor=mpl_style.line_color, 63 | linewidth=mpl_style.line_width, 64 | hatch=mpl_style.fill_pattern, 65 | ) 66 | 67 | # if mpl_style.shadow: 68 | # http://matplotlib.sourceforge.net/users/transforms_tutorial.html 69 | # #using-offset-transforms-to-create-a-shadow-effect 70 | # shift the object over 2 points, and down 2 points 71 | # dx, dy = mpl_style.shadow / 72.0, -mpl_style.shadow / 72.0 72 | # offset = transforms.ScaledTranslation(dx, dy, fig.dpi_scale_trans) 73 | # shadow_transform = axes.transData + offset 74 | # # now plot the same data with our offset transform; 75 | # # use the zorder to make sure we are below the line 76 | # axes.plot( 77 | # x, 78 | # y, 79 | # linewidth=mpl_style.line_width, 80 | # color="gray", 81 | # transform=shadow_transform, 82 | # zorder=0.5 * line.get_zorder(), 83 | # ) 84 | 85 | def _plot_arrow(self, x, y, dx, dy, style: Style, axes: plt.Axes): 86 | """Draw arrow (dx,dy) at (x,y). `style` is '->', '<-' or '<->'.""" 87 | if style.arrow == Style.ArrowStyle.DOUBLE: 88 | raise ValueError("Only a single ended arrow is supported by this method.") 89 | mpl_style = MatplotlibStyle(style) 90 | if style.arrow == Style.ArrowStyle.END: 91 | x = x + dx 92 | y = y + dy 93 | dx = -dx 94 | dy = -dy 95 | axes.arrow( 96 | x, 97 | y, 98 | dx, 99 | dy, 100 | facecolor=mpl_style.line_color, 101 | edgecolor=mpl_style.line_color, 102 | linestyle=mpl_style.line_style, 103 | linewidth=mpl_style.line_width, 104 | head_width=0.05, 105 | # head_width=self.arrow_head_width, 106 | # width=1, # width of arrow body in coordinate scale 107 | length_includes_head=True, 108 | shape="full", 109 | ) 110 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_style.py: -------------------------------------------------------------------------------- 1 | from pysketcher._style import Style, TextStyle 2 | 3 | 4 | class MatplotlibStyle: 5 | _style: Style 6 | LINE_STYLE_MAP = { 7 | Style.LineStyle.SOLID: "-", 8 | Style.LineStyle.DOTTED: ":", 9 | Style.LineStyle.DASHED: "--", 10 | Style.LineStyle.DASH_DOT: "-.", 11 | } 12 | FILL_PATTERN_MAP = { 13 | Style.FillPattern.CIRCLE: "O", 14 | Style.FillPattern.CROSS: "x", 15 | Style.FillPattern.DOT: ".", 16 | Style.FillPattern.HORIZONTAL: "-", 17 | Style.FillPattern.SQUARE: "+", 18 | Style.FillPattern.STAR: "*", 19 | Style.FillPattern.SMALL_CIRCLE: "o", 20 | Style.FillPattern.VERTICAL: "|", 21 | Style.FillPattern.UP_LEFT_TO_RIGHT: "//", 22 | Style.FillPattern.UP_RIGHT_TO_LEFT: "\\\\", 23 | } 24 | COLOR_MAP = { 25 | Style.Color.GREY: "grey", 26 | Style.Color.BLACK: "black", 27 | Style.Color.BLUE: "blue", 28 | Style.Color.BROWN: "brown", 29 | Style.Color.CYAN: "cyan", 30 | Style.Color.GREEN: "green", 31 | Style.Color.MAGENTA: "magenta", 32 | Style.Color.ORANGE: "orange", 33 | Style.Color.PURPLE: "purple", 34 | Style.Color.RED: "red", 35 | Style.Color.YELLOW: "yellow", 36 | Style.Color.WHITE: "white", 37 | } 38 | ARROW_MAP = { 39 | Style.ArrowStyle.DOUBLE: "<->", 40 | Style.ArrowStyle.START: "<-", 41 | Style.ArrowStyle.END: "->", 42 | } 43 | 44 | def __init__(self, style: Style): 45 | self._style = style 46 | 47 | @property 48 | def line_width(self) -> float: 49 | return self._style.line_width 50 | 51 | @property 52 | def line_style(self) -> str: 53 | return self.LINE_STYLE_MAP.get(self._style.line_style) 54 | 55 | @property 56 | def line_color(self): 57 | return self.COLOR_MAP.get(self._style.line_color) 58 | 59 | @property 60 | def fill_color(self): 61 | return self.COLOR_MAP.get(self._style.fill_color) 62 | 63 | @property 64 | def fill_pattern(self): 65 | return self.FILL_PATTERN_MAP.get(self._style.fill_pattern) 66 | 67 | @property 68 | def arrow(self): 69 | return self.ARROW_MAP.get(self._style.arrow) 70 | 71 | @property 72 | def shadow(self): 73 | return self._style.shadow 74 | 75 | def __str__(self): 76 | return ( 77 | "line_style: %s, line_width: %s, line_color: %s," 78 | " fill_pattern: %s, fill_color: %s, arrow: %s shadow: %s" 79 | % ( 80 | self.line_style, 81 | self.line_width, 82 | self.line_color, 83 | self.fill_pattern, 84 | self.fill_color, 85 | self.arrow, 86 | self.shadow, 87 | ) 88 | ) 89 | 90 | 91 | class MatplotlibTextStyle(MatplotlibStyle): 92 | FONT_FAMILY_MAP = { 93 | TextStyle.FontFamily.SERIF: "serif", 94 | TextStyle.FontFamily.SANS: "sans-serif", 95 | TextStyle.FontFamily.MONO: "monospace", 96 | } 97 | 98 | ALIGNMENT_MAP = { 99 | TextStyle.Alignment.LEFT: "left", 100 | TextStyle.Alignment.RIGHT: "right", 101 | TextStyle.Alignment.CENTER: "center", 102 | } 103 | 104 | _style: TextStyle 105 | 106 | def __init__(self, text_style: TextStyle): 107 | super().__init__(text_style) 108 | 109 | @property 110 | def font_size(self) -> float: 111 | return self._style.font_size 112 | 113 | @property 114 | def font_family(self) -> str: 115 | return self.FONT_FAMILY_MAP.get(self._style.font_family) 116 | 117 | @property 118 | def alignment(self) -> str: 119 | return self.ALIGNMENT_MAP.get(self._style.alignment) 120 | -------------------------------------------------------------------------------- /pysketcher/backend/matplotlib/_matplotlib_text.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | 3 | from pysketcher._text import Text 4 | from pysketcher.backend.matplotlib._matplotlib_adapter import MatplotlibAdapter 5 | from pysketcher.backend.matplotlib._matplotlib_style import MatplotlibTextStyle 6 | 7 | 8 | class MatplotlibText(MatplotlibAdapter): 9 | """Renders a Text primitive.""" 10 | 11 | def plot(self, text: Text, axes: plt.Axes): 12 | """Render a Text primitive. 13 | 14 | Write `text` string at a position (centered, left, right - according 15 | to the `alignment` string). `position` is a point in the coordinate 16 | system. 17 | 18 | Args: 19 | text: the ``Text`` object to be rendered. 20 | axes: the ``Axes`` to render the text object on. 21 | """ 22 | mpl_style = MatplotlibTextStyle(text.style) 23 | kwargs = {} 24 | if mpl_style.font_family is not None: 25 | kwargs["family"] = mpl_style.font_family 26 | if mpl_style.fill_color is not None: 27 | kwargs["backgroundcolor"] = mpl_style.fill_color 28 | if mpl_style.line_color is not None: 29 | kwargs["color"] = mpl_style.line_color 30 | 31 | rotation_angle = text.direction.angle 32 | if rotation_angle != 0.0: 33 | kwargs["rotation"] = rotation_angle 34 | 35 | axes.text( 36 | text.position.x, 37 | text.position.y, 38 | text.text, 39 | horizontalalignment=mpl_style.alignment, 40 | fontsize=mpl_style.font_size, 41 | **kwargs, 42 | ) 43 | -------------------------------------------------------------------------------- /pysketcher/composition/__init__.py: -------------------------------------------------------------------------------- 1 | """Provides a means to group primitive shapes. 2 | 3 | Primitives can be combined together to form reusable 4 | groups which can then be transformed. This module also 5 | provides a set of pre-baked primitives for convenience. 6 | """ 7 | 8 | from pysketcher.composition._composition import Composition 9 | 10 | __all__ = ["Composition"] 11 | -------------------------------------------------------------------------------- /pysketcher/dimension/__init__.py: -------------------------------------------------------------------------------- 1 | """Provide a means to dimension shapes.""" 2 | from pysketcher.dimension._angular_dimension import AngularDimension 3 | from pysketcher.dimension._linear_dimension import LinearDimension 4 | from pysketcher.dimension._radial_dimension import RadialDimension 5 | 6 | __all__ = ["AngularDimension", "LinearDimension", "RadialDimension"] 7 | -------------------------------------------------------------------------------- /pysketcher/dimension/_angular_dimension.py: -------------------------------------------------------------------------------- 1 | from enum import auto, Enum, unique 2 | from typing import Dict 3 | 4 | import numpy as np 5 | 6 | from pysketcher._arc import Arc 7 | from pysketcher._line import Line 8 | from pysketcher._point import Point 9 | from pysketcher._shape import Shape 10 | from pysketcher._style import Style 11 | from pysketcher.annotation import ArcAnnotation 12 | from pysketcher.composition import Composition 13 | 14 | 15 | class AngularDimension(Composition): 16 | """Used to indicate the angle between two points about a given center. 17 | 18 | Args: 19 | start: the point from which the angle should be indicated 20 | end: the point to which the angle should be indicated 21 | center: the point about which the angle should be indicated 22 | 23 | returns: A composition. 24 | 25 | Examples: 26 | >>> arc1 = ps.Arc(ps.Point(0.25, 0.25), 1.0, ps.Angle(0.0), ps.Angle(np.pi / 2)) 27 | >>> arc1.style.line_color = ps.Style.Color.BLUE 28 | >>> dim1 = ps.AngularDimension("$a$", arc1.start, arc1.end, arc1.center) 29 | >>> 30 | >>> arc2 = ps.Arc( 31 | ... ps.Point(0.25, -0.25), 1.0, ps.Angle(0.0), ps.Angle(-np.pi / 2) 32 | ... ) 33 | >>> arc2.style.line_color = ps.Style.Color.GREEN 34 | >>> dim2 = ps.AngularDimension("$b$", arc2.start, arc2.end, arc2.center) 35 | >>> 36 | >>> arc3 = ps.Arc( 37 | ... ps.Point(-0.25, 0.25), 1.0, ps.Angle(np.pi / 2), ps.Angle(np.pi / 2) 38 | ... ) 39 | >>> arc3.style.line_color = ps.Style.Color.RED 40 | >>> dim3 = ps.AngularDimension("$c$", arc3.start, arc3.end, arc3.center) 41 | >>> 42 | >>> fig = ps.Figure(-2.0, 2.0, -2.0, 2.0, backend=MatplotlibBackend) 43 | >>> fig.add(arc1) 44 | >>> fig.add(dim1) 45 | >>> fig.add(arc2) 46 | >>> fig.add(dim2) 47 | >>> fig.add(arc3) 48 | >>> fig.add(dim3) 49 | >>> fig.save("pysketcher/images/angular_dimension.png") 50 | """ 51 | 52 | @unique 53 | class Orientation(Enum): 54 | """Specifies if the dimension should be drawn inside or outside the angle.""" 55 | 56 | INTERNAL = auto() 57 | EXTERNAL = auto() 58 | 59 | _DEFAULT_OFFSET: float = 0.5 60 | _DEFAULT_MINOR_OFFSET: float = 0.1 61 | _DEFAULT_ORIENTATION: Orientation = Orientation.EXTERNAL 62 | _DEFAULT_EXTENSION_LINES: bool = True 63 | 64 | def __init__(self, text: str, start: Point, end: Point, center: Point): 65 | self._text = text 66 | self._start = start 67 | self._end = end 68 | self._center = center 69 | self._offset = self._DEFAULT_OFFSET 70 | self._minor_offset = self._DEFAULT_MINOR_OFFSET 71 | self._orientation = self._DEFAULT_ORIENTATION 72 | self._extension_lines = self._DEFAULT_EXTENSION_LINES 73 | super().__init__(self._generate_shapes()) 74 | 75 | def _generate_shapes(self) -> Dict[str, Shape]: 76 | shapes = self._generate_extension_lines() 77 | 78 | # TODO: code a flag to indicate the outside angle 79 | self._start_angle = (self._start - self._center).angle 80 | self._end_angle = (self._end - self._center).angle 81 | if abs(self._end.angle - self._start.angle) > np.pi: 82 | self._start_angle, self._end_angle = self._end_angle, self._start_angle 83 | 84 | arc = Arc( 85 | self._center, 86 | abs(self._center - self._start) + self._offset, 87 | self._start_angle, 88 | (self._end_angle - self._start_angle), 89 | ).set_arrow(Style.ArrowStyle.DOUBLE) 90 | shapes["arrow"] = arc 91 | shapes["annotation"] = ArcAnnotation(self._text, arc) 92 | 93 | return shapes 94 | 95 | def _generate_extension_lines(self) -> Dict[str, Shape]: 96 | extension_lines = {} 97 | 98 | def extension_line_vector(p: Point, c: Point): 99 | if self._orientation == self.Orientation.EXTERNAL: 100 | vec = (p - c).unit_vector 101 | elif self._orientation == self.Orientation.INTERNAL: 102 | vec = (p - c).unit_vector * -1 103 | else: 104 | raise ValueError(f"Invalid value for Orientation: {self._orientation}") 105 | return vec 106 | 107 | if self._extension_lines: 108 | extension_line1_vector = extension_line_vector(self._start, self._center) 109 | extension_lines["extension_line_1"] = Line( 110 | self._start + extension_line1_vector * self._minor_offset, 111 | self._start 112 | + extension_line1_vector * (self._offset + self._minor_offset), 113 | ) 114 | 115 | extension_line2_vector = extension_line_vector(self._end, self._center) 116 | extension_lines["extension_line_2"] = Line( 117 | self._end + extension_line2_vector * self._minor_offset, 118 | self._end 119 | + extension_line2_vector * (self._offset + self._minor_offset), 120 | ) 121 | return extension_lines 122 | 123 | @property 124 | def start(self) -> Point: 125 | """The start of the dimension.""" 126 | return self._start 127 | 128 | @property 129 | def end(self) -> Point: 130 | """The end of the dimension.""" 131 | return self._end 132 | 133 | @property 134 | def center(self) -> Point: 135 | """The center of the dimension.""" 136 | return self._center 137 | 138 | @property 139 | def extension_lines(self) -> bool: 140 | """If true, extension lines will be drawn.""" 141 | return self._extension_lines 142 | 143 | @extension_lines.setter 144 | def extension_lines(self, extension_lines): 145 | self._extension_lines = extension_lines 146 | self._shapes = self._generate_shapes() 147 | -------------------------------------------------------------------------------- /pysketcher/dimension/_radial_dimension.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from pysketcher._arrow import Arrow 4 | from pysketcher._line import Line 5 | from pysketcher._point import Point 6 | from pysketcher._shape import Shape 7 | from pysketcher.annotation import LineAnnotation, TextPosition 8 | from pysketcher.composition import Composition 9 | 10 | 11 | class RadialDimension(Composition): 12 | """Used to indicate radial distances. 13 | 14 | Examples: 15 | >>> circle1 = ps.Circle(ps.Point(1.5, 1.5), 0.8) 16 | >>> circle1.style.line_width = 1 17 | >>> dim1 = RadialDimension(r"$r$", circle1.center, circle1(np.pi / 3)) 18 | >>> 19 | >>> circle2 = ps.Circle(ps.Point(3.5, 1.5), 0.8) 20 | >>> circle2.style.line_width = 1 21 | >>> dim2 = RadialDimension(r"$r$", circle2.center, circle2(np.pi / 3)) 22 | >>> dim2.diameter = True 23 | >>> 24 | >>> circle3 = ps.Circle(ps.Point(5.5, 1.5), 0.8) 25 | >>> circle3.style.line_width = 1 26 | >>> dim3 = RadialDimension(r"$r$", circle3.center, circle3(np.pi / 3)) 27 | >>> dim3.center_mark = True 28 | >>> 29 | >>> circle4 = ps.Circle(ps.Point(7.5, 1.5), 0.8) 30 | >>> circle4.style.line_width = 1 31 | >>> dim4 = RadialDimension(r"$r$", circle4.center, circle4(np.pi / 3)) 32 | >>> dim4.center_line = True 33 | >>> 34 | >>> circle5 = ps.Circle(ps.Point(9.5, 1.5), 0.8) 35 | >>> circle5.style.line_width = 1 36 | >>> dim5 = RadialDimension(r"$r$", circle5.center, circle5(np.pi / 3)) 37 | >>> dim5.diameter = True 38 | >>> dim5.center_line = True 39 | >>> 40 | >>> fig = ps.Figure(0, 11, 0, 3, backend=MatplotlibBackend) 41 | >>> fig.add(circle1) 42 | >>> fig.add(dim1) 43 | >>> fig.add(circle2) 44 | >>> fig.add(dim2) 45 | >>> fig.add(circle3) 46 | >>> fig.add(dim3) 47 | >>> fig.add(circle4) 48 | >>> fig.add(dim4) 49 | >>> fig.add(circle5) 50 | >>> fig.add(dim5) 51 | >>> fig.save("pysketcher/images/radial_dimension.png") 52 | 53 | .. figure:: images/radial_dimension.png 54 | :alt: An example of LinearDimension. 55 | :figclass: align-center 56 | :scale: 30% 57 | 58 | An example of ``RadialDimension``. 59 | """ 60 | 61 | _DEFAULT_DIAMETER: bool = False 62 | _DEFAULT_CENTER_LINE: bool = False 63 | _DEFAULT_CENTER_MARK: bool = False 64 | 65 | _DEFAULT_OFFSET: float = 0.2 66 | 67 | def __init__(self, text: str, center: Point, edge: Point): 68 | self._text = text 69 | self._center = center 70 | self._edge = edge 71 | self._offset = self._DEFAULT_OFFSET 72 | self._diameter = self._DEFAULT_DIAMETER 73 | self._center_line = self._DEFAULT_CENTER_LINE 74 | self._center_mark = self._DEFAULT_CENTER_MARK 75 | super().__init__(self._generate_shapes()) 76 | 77 | def _generate_shapes(self) -> Dict[str, Shape]: 78 | arrow1 = Arrow( 79 | self._edge + (self._edge - self._center) * self._offset, self._edge 80 | ) 81 | text = LineAnnotation(self._text, arrow1, TextPosition.START) 82 | text.style.font_size = 24 83 | ret_dict = {"arrow1": arrow1, "text": text} 84 | 85 | if self._diameter: 86 | inward_vector = self._center - self._edge 87 | offset_vector = inward_vector.unit_vector * self._offset 88 | start = self._edge + inward_vector * 2 + offset_vector 89 | end = self._edge + inward_vector * 2 90 | 91 | ret_dict["arrow2"] = Arrow(start, end) 92 | 93 | if self._center_mark: 94 | ret_dict["center_mark_h"] = Line( 95 | self._center - Point(-self._offset * 0.5, 0), 96 | self._center - Point(self._offset * 0.5, 0), 97 | ) 98 | ret_dict["center_mark_v"] = Line( 99 | self._center - Point(0, -self._offset * 0.5), 100 | self._center - Point(0, self._offset * 0.5), 101 | ) 102 | 103 | if self._center_line: 104 | ret_dict["center_line"] = Line(self._edge, self._center) 105 | 106 | if self._center_line and self._diameter: 107 | ret_dict["center_line2"] = Line( 108 | self._center, self._center + (self._center - self._edge) 109 | ) 110 | return ret_dict 111 | 112 | @property 113 | def diameter(self) -> bool: 114 | """If true, the dimension indicates diameter rather than radius.""" 115 | return self._diameter 116 | 117 | @diameter.setter 118 | def diameter(self, diameter: bool): 119 | self._diameter = diameter 120 | self._shapes = self._generate_shapes() 121 | 122 | @property 123 | def center_mark(self) -> bool: 124 | """If true, a center mark will be drawn.""" 125 | return self._center_mark 126 | 127 | @center_mark.setter 128 | def center_mark(self, center_mark: bool): 129 | self._center_mark = center_mark 130 | self._shapes = self._generate_shapes() 131 | 132 | @property 133 | def center_line(self) -> bool: 134 | """If true then a line will be drawn from the center to the circumference.""" 135 | return self._center_line 136 | 137 | @center_line.setter 138 | def center_line(self, center_line: bool): 139 | self._center_line = center_line 140 | self._shapes = self._generate_shapes() 141 | -------------------------------------------------------------------------------- /pysketcher/images/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/pysketcher/images/.gitignore -------------------------------------------------------------------------------- /pysketcher/shapes.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/pysketcher/shapes.pyc -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/tests/__init__.py -------------------------------------------------------------------------------- /tests/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | from ._float import make_angle, make_float 2 | 3 | __all__ = ["make_angle", "make_float"] 4 | -------------------------------------------------------------------------------- /tests/strategies/_float.py: -------------------------------------------------------------------------------- 1 | from hypothesis.strategies import builds, floats, one_of, SearchStrategy 2 | 3 | import pysketcher as ps 4 | 5 | mx = 1e30 6 | mn = 1e-30 7 | 8 | 9 | def make_float(max: float = mx, min: float = mn) -> SearchStrategy[float]: 10 | strategy = floats( 11 | min_value=min, 12 | max_value=max, 13 | allow_nan=False, 14 | allow_infinity=False, 15 | allow_subnormal=False, 16 | ) 17 | return strategy 18 | 19 | 20 | def make_angle() -> SearchStrategy[ps.Angle]: 21 | def angle_float(): 22 | return one_of( 23 | floats( 24 | min_value=mn, 25 | max_value=mx, 26 | allow_nan=False, 27 | allow_infinity=False, 28 | allow_subnormal=False, 29 | ), 30 | floats( 31 | max_value=-mn, 32 | min_value=-mx, 33 | allow_nan=False, 34 | allow_infinity=False, 35 | allow_subnormal=False, 36 | ), 37 | ) 38 | 39 | return builds(ps.Angle, angle_float()) 40 | -------------------------------------------------------------------------------- /tests/test_angle.py: -------------------------------------------------------------------------------- 1 | from hypothesis import example, given, note 2 | from hypothesis.strategies import booleans, floats 3 | import numpy as np 4 | 5 | from pysketcher import Angle 6 | from tests.strategies import make_angle, make_float 7 | 8 | 9 | class TestAngle: 10 | @given(make_float()) 11 | @example(8725732868031747.0) 12 | @example(8726832379593987.0) 13 | def test_range(self, x: float): 14 | a = Angle(x) 15 | assert -np.pi < a 16 | assert a <= np.pi 17 | 18 | @given(make_float()) 19 | def test_equality(self, x: float): 20 | if -np.pi < x < np.pi: 21 | assert x == Angle(x) 22 | else: 23 | assert Angle(x) == Angle(x) 24 | 25 | @given(make_angle(), make_float()) 26 | def test_addition(self, a: Angle, b: Angle): 27 | c = a + b 28 | assert type(c) == Angle 29 | assert -np.pi <= c 30 | assert c <= np.pi 31 | 32 | @given(make_angle(), make_angle()) 33 | def test_subtraction(self, a: Angle, b: Angle): 34 | c = a - b 35 | assert type(c) == Angle 36 | assert -np.pi <= c 37 | assert c <= np.pi 38 | 39 | @given(make_angle(), make_float()) 40 | def test_multiplication(self, a: Angle, b: float): 41 | c = a * b 42 | assert type(c) == Angle 43 | assert c <= np.pi 44 | assert -np.pi < c 45 | 46 | @given(make_angle(), floats(min_value=1e-6, max_value=1e6), booleans()) 47 | def test_division(self, a: Angle, b: float, negate: bool): 48 | if negate: 49 | b = -b 50 | c = a / b 51 | note(c) 52 | assert type(c) == Angle 53 | assert -np.pi <= c 54 | assert c <= np.pi 55 | -------------------------------------------------------------------------------- /tests/test_compositions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/tests/test_compositions/__init__.py -------------------------------------------------------------------------------- /tests/test_compositions/test_composition.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given 2 | from hypothesis.strategies import floats, sampled_from 3 | import pytest 4 | 5 | from pysketcher import Line, Point, Shape, Style, Text 6 | from pysketcher.composition import Composition 7 | 8 | 9 | class TestCompositionStyle: 10 | @pytest.fixture(scope="module") 11 | def composition(self): 12 | shape1 = Line(Point(0, 1), Point(1, 1)) 13 | shape2 = Line(Point(1, 1), Point(0, 2)) 14 | text = Text("This is a test.", Point(2, 2)) 15 | composition = Composition( 16 | { 17 | "shape1": shape1, 18 | "shape2": shape2, 19 | "test": text, 20 | } 21 | ) 22 | return composition 23 | 24 | @given(sampled_from(Style.LineStyle)) 25 | def test_line_style(self, composition: Composition, line_style: Style.LineStyle): 26 | composition.style.line_style = line_style 27 | assert composition["shape1"].style.line_style == line_style 28 | assert composition["shape2"].style.line_style == line_style 29 | 30 | @given(floats(allow_nan=False, allow_infinity=False)) 31 | def test_line_width(self, composition: Composition, line_width: float): 32 | composition.style.line_width = line_width 33 | assert composition["shape1"].style.line_width == line_width 34 | assert composition["shape2"].style.line_width == line_width 35 | 36 | @given(sampled_from(Style.Color)) 37 | def test_line_color(self, composition: Composition, line_color: Style.Color): 38 | composition.style.line_color = line_color 39 | assert composition["shape1"].style.line_color == line_color 40 | assert composition["shape2"].style.line_color == line_color 41 | 42 | @given(sampled_from(Style.Color)) 43 | def test_fill_color(self, composition: Composition, fill_color: Style.Color): 44 | composition.style.fill_color = fill_color 45 | assert composition["shape1"].style.fill_color == fill_color 46 | assert composition["shape2"].style.fill_color == fill_color 47 | 48 | @given(sampled_from(Style.FillPattern)) 49 | def test_fill_pattern( 50 | self, composition: Composition, fill_pattern: Style.FillPattern 51 | ): 52 | composition.style.fill_pattern = fill_pattern 53 | assert composition["shape1"].style.fill_pattern == fill_pattern 54 | assert composition["shape2"].style.fill_pattern == fill_pattern 55 | 56 | @given(sampled_from(Style.ArrowStyle)) 57 | def test_arrow(self, composition: Composition, arrow: Style.ArrowStyle): 58 | composition.style.arrow = arrow 59 | assert composition["shape1"].style.arrow == arrow 60 | assert composition["shape2"].style.arrow == arrow 61 | 62 | @given(floats(allow_nan=False, allow_infinity=False)) 63 | def test_shadow(self, composition: Composition, shadow: float): 64 | composition.style.shadow = shadow 65 | assert composition["shape1"].style.shadow == shadow 66 | assert composition["shape2"].style.shadow == shadow 67 | 68 | def test_iteration(self, composition: Composition): 69 | for shape in composition: 70 | assert isinstance(shape, Shape) 71 | -------------------------------------------------------------------------------- /tests/test_line.py: -------------------------------------------------------------------------------- 1 | from hypothesis import assume, HealthCheck, settings 2 | 3 | from pysketcher import Line, Point 4 | from tests.utils import given_inferred 5 | 6 | 7 | class TestLine: 8 | @given_inferred 9 | @settings(suppress_health_check=[HealthCheck.filter_too_much]) 10 | def test_start(self, a: Point, b: Point) -> None: 11 | assume(a != b) 12 | line = Line(a, b) 13 | assert line.start == a 14 | assert line.end == b 15 | 16 | # def test_rotate(self, line: Line, center: Point, theta: float, expected: Line): 17 | # result = line.rotate(theta, center) 18 | # assert abs(result.start - expected.start) < 1e-14 19 | # assert abs(result.end - expected.end) < 1e-14 20 | -------------------------------------------------------------------------------- /tests/test_point.py: -------------------------------------------------------------------------------- 1 | from math import inf 2 | 3 | from hypothesis import assume, given, note 4 | from hypothesis.strategies import builds 5 | import numpy as np 6 | import pytest 7 | 8 | from pysketcher import Angle, Point 9 | from tests.strategies import make_angle, make_float 10 | from tests.utils import isclose 11 | 12 | 13 | def make_point(): 14 | return builds(Point, make_float(), make_float()) 15 | 16 | 17 | class TestPoint: 18 | @given(make_float(), make_float()) 19 | def test_coordinates(self, x: float, y: float) -> None: 20 | p = Point(x, y) 21 | assert p.x == x 22 | assert p.y == y 23 | 24 | @given(make_float(), make_float()) 25 | def test_equality(self, x: float, y: float) -> None: 26 | assert Point(x, y) == Point(x, y) 27 | 28 | @given(make_float(), make_float(), make_float(), make_float()) 29 | def test_adding(self, x1: float, x2: float, y1: float, y2: float): 30 | a = Point(x1, y1) 31 | b = Point(x2, y2) 32 | assert a + b == Point(x1 + x2, y1 + y2) 33 | 34 | @given(make_float(), make_float(), make_float(), make_float()) 35 | def test_translation(self, x1: float, x2: float, y1: float, y2: float): 36 | a = Point(x1, y1) 37 | b = Point(x2, y2) 38 | assert a + b == Point(x1 + x2, y1 + y2) 39 | 40 | @given(make_float(), make_float(), make_float(), make_float()) 41 | def test_subtraction(self, x1: float, x2: float, y1: float, y2: float): 42 | a = Point(x1, y1) 43 | b = Point(x2, y2) 44 | assert a - b == Point(x1 - x2, y1 - y2) 45 | 46 | @given(make_float(), make_float(), make_float()) 47 | def test_multiplication(self, x: float, y: float, s: float): 48 | a = Point(x, y) 49 | assert a * s == Point(x * s, y * s) 50 | 51 | @given(make_float(), make_float(), make_float()) 52 | def test_scale(self, x: float, y: float, s: float): 53 | a = Point(x, y) 54 | assert a.scale(s) == Point(x * s, y * s) 55 | 56 | @given(make_float(), make_float()) 57 | def test_abs(self, x: float, y: float): 58 | assume(x * x != inf) 59 | assume(y * y != inf) 60 | a = Point(x, y) 61 | assert abs(a) == np.hypot(x, y) 62 | 63 | @given(make_point()) 64 | def test_angle(self, a: Point): 65 | angle = a.angle 66 | note(angle) 67 | b = Point(abs(a), 0.0).rotate(angle, Point(0.0, 0.0)) 68 | note(f"The angle is : {np.format_float_scientific(a.angle)}") 69 | note(f"The length is : {np.format_float_scientific(abs(a))}") 70 | assert -np.pi <= angle <= np.pi 71 | if 1e-4 < a.x < 1e4 and 1e-4 < a.y < 1e4: 72 | assert isclose(b.x, a.x) and isclose(b.y, a.y) 73 | 74 | @given(make_float(), make_float()) 75 | def test_unit_vector(self, x: float, y: float): 76 | a = Point(x, y) 77 | if isclose(abs(a), 0.0): 78 | with pytest.raises(ZeroDivisionError): 79 | a.unit_vector 80 | else: 81 | b = a.unit_vector 82 | note(f"angle of a: {np.format_float_scientific(a.angle)}") 83 | note(f"angle of b: {np.format_float_scientific(b.angle)}") 84 | assert isclose(a.angle, b.angle) 85 | note(f"magnitude of b: {abs(b)}") 86 | assert isclose(abs(b), 1.0) 87 | 88 | @given(make_point()) 89 | def test_normal_vector(self, a: Point): 90 | if isclose(abs(a), 0.0): 91 | with pytest.raises(ZeroDivisionError): 92 | a.normal 93 | else: 94 | angle = a.normal.angle - a.angle 95 | assert isclose(angle, np.pi / 2.0) 96 | 97 | @given(make_point(), make_angle()) 98 | def test_rotation_about_zero(self, a: Point, angle: Angle): 99 | assume(abs(a) != 0) 100 | b = a.rotate(angle, Point(0.0, 0.0)) 101 | aa = a.angle 102 | bb = b.angle 103 | note(f"a angle: {aa}") 104 | note(f"b angle: {bb}") 105 | assert isclose(bb - aa, angle) 106 | 107 | @given(make_point(), make_angle(), make_point()) 108 | def test_rotation(self, a: Point, angle: Angle, center: Point): 109 | assume(abs(a - center) != 0) 110 | b = a.rotate(angle, center) 111 | new_angle = (b - center).angle - (a - center).angle 112 | note(str(angle)) 113 | note(str(new_angle)) 114 | assert isclose(angle, angle) 115 | -------------------------------------------------------------------------------- /tests/test_shape.py: -------------------------------------------------------------------------------- 1 | from hypothesis import HealthCheck, settings 2 | 3 | from pysketcher import Shape, Style 4 | from tests.utils import given_inferred 5 | 6 | 7 | class TestShape: 8 | @given_inferred 9 | @settings(suppress_health_check=[HealthCheck.filter_too_much, HealthCheck.too_slow]) 10 | def test_style(self, shape: Shape): 11 | style = Style() 12 | shape.style = style 13 | assert shape.style == style 14 | 15 | @given_inferred 16 | def test_set_line_width(self, shape: Shape, line_width: float): 17 | new_shape = shape.set_line_width(line_width) 18 | assert new_shape == shape 19 | assert new_shape.style.line_width == line_width 20 | 21 | @given_inferred 22 | def test_set_line_style(self, shape: Shape, line_style: Style.LineStyle): 23 | new_shape = shape.set_line_style(line_style) 24 | assert new_shape == shape 25 | assert new_shape.style.line_style == line_style 26 | 27 | @given_inferred 28 | def test_set_line_color(self, shape: Shape, color: Style.Color): 29 | new_shape = shape.set_line_color(color) 30 | assert new_shape == shape 31 | assert new_shape.style.line_color == color 32 | 33 | @given_inferred 34 | def test_set_fill_color(self, shape: Shape, color: Style.Color): 35 | new_shape = shape.set_fill_color(color) 36 | assert new_shape == shape 37 | assert new_shape.style.fill_color == color 38 | 39 | @given_inferred 40 | def test_set_fill_pattern(self, shape: Shape, fill_pattern: Style.FillPattern): 41 | new_shape = shape.set_fill_pattern(fill_pattern) 42 | assert new_shape == shape 43 | assert new_shape.style.fill_pattern == fill_pattern 44 | 45 | @given_inferred 46 | def test_set_arrow(self, shape: Shape, arrow: Style.ArrowStyle): 47 | new_shape = shape.set_arrow(arrow) 48 | assert new_shape == shape 49 | assert new_shape.style.arrow == arrow 50 | 51 | @given_inferred 52 | def test_set_shadow(self, shape: Shape, shadow: float): 53 | new_shape = shape.set_shadow(shadow) 54 | assert new_shape == shape 55 | assert new_shape.style.shadow == shadow 56 | -------------------------------------------------------------------------------- /tests/test_style.py: -------------------------------------------------------------------------------- 1 | from pysketcher import Style 2 | from tests.utils import given_inferred 3 | 4 | 5 | class TestStyle(object): 6 | @given_inferred 7 | def test_line_style(self, style: Style, line_style: Style.LineStyle): 8 | style.line_style = line_style 9 | assert style.line_style == line_style 10 | 11 | @given_inferred 12 | def test_line_width(self, style: Style, width: float): 13 | style.line_width = width 14 | assert style.line_width == width 15 | 16 | @given_inferred 17 | def test_line_color(self, style: Style, line_color: Style.Color): 18 | style.line_color = line_color 19 | assert style.line_color == line_color 20 | 21 | @given_inferred 22 | def test_fill_color(self, style: Style, fill_color: Style.Color): 23 | style.fill_color = fill_color 24 | assert style.fill_color == fill_color 25 | 26 | @given_inferred 27 | def test_fill_pattern(self, style: Style, fill_pattern: Style.FillPattern): 28 | style.fill_pattern = fill_pattern 29 | assert style.fill_pattern == fill_pattern 30 | 31 | @given_inferred 32 | def test_arrow(self, style: Style, arrow: Style.ArrowStyle): 33 | style.arrow = arrow 34 | assert style.arrow == arrow 35 | 36 | @given_inferred 37 | def test_shadow(self, style: Style, shadow: float): 38 | style.shadow = shadow 39 | assert style.shadow == shadow 40 | 41 | 42 | # 43 | # 44 | # class TestTextStyle(TestStyle): 45 | # @given(from_type(Style), floats(allow_infinity=False, allow_nan=False)) 46 | # def test_font_size(self, style: TextStyle, font_size: float): 47 | # style.font_size = font_size 48 | # assert style.font_size == font_size 49 | # 50 | # @given(from_type(Style), sampled_from(TextStyle.FontFamily)) 51 | # def test_font_family(self, style: TextStyle, font_family: TextStyle.FontFamily): 52 | # style.font_family = font_family 53 | # assert style.font_family == font_family 54 | # 55 | # @given(from_type(Style), sampled_from(TextStyle.Alignment)) 56 | # def test_alignment(self, style: TextStyle, alignment: TextStyle.Alignment): 57 | # style.alignment = alignment 58 | # assert style.alignment == alignment 59 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ImageComparisonFailure 2 | from .given_inferred import given_inferred 3 | from .is_close import isclose 4 | 5 | __all__ = ["isclose", "ImageComparisonFailure", "given_inferred"] 6 | -------------------------------------------------------------------------------- /tests/utils/base_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/tests/utils/base_image.png -------------------------------------------------------------------------------- /tests/utils/compare_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, Union 3 | 4 | import numpy as np 5 | from PIL import Image 6 | 7 | from tests.utils import ImageComparisonFailure 8 | 9 | 10 | def make_test_filename(file_name, purpose): 11 | """Make a new filename by inserting *purpose* before the file's extension.""" 12 | base, ext = os.path.splitext(file_name) 13 | return "%s-%s%s" % (base, purpose, ext) 14 | 15 | 16 | def calculate_rms(expected_image, actual_image): 17 | """Calculate the per-pixel errors, then compute the root mean square error.""" 18 | if expected_image.shape != actual_image.shape: 19 | raise ImageComparisonFailure( 20 | "Image sizes do not match expected size: {} " 21 | "actual size {}".format(expected_image.shape, actual_image.shape) 22 | ) 23 | # Convert to float to avoid overflowing finite integer types. 24 | return np.sqrt(((expected_image - actual_image).astype(float) ** 2).mean()) 25 | 26 | 27 | def compare_images(expected: str, actual: str, tol: float) -> Union[None, Dict]: 28 | """Compare two "image" files checking differences within a tolerance. 29 | 30 | The two given filenames may point to files which are convertible to 31 | PNG via the `.converter` dictionary. The underlying RMS is calculated 32 | with the `.calculate_rms` function. 33 | 34 | Args: 35 | expected : The filename of the expected image. 36 | actual : The filename of the actual image. 37 | tol : The tolerance (a color value difference, where 255 is the 38 | maximal difference). The test fails if the average pixel 39 | difference is greater than this value. 40 | 41 | Returns: 42 | Return *None* if the images are equal within the given tolerance. 43 | If the images differ, the return value depends on *in_decorator*. 44 | If *in_decorator* is true, a dict with the following entries is 45 | returned: 46 | - *rms*: The RMS of the image difference. 47 | - *expected*: The filename of the expected image. 48 | - *actual*: The filename of the actual image. 49 | - *diff_image*: The filename of the difference image. 50 | - *tol*: The comparison tolerance. 51 | 52 | Raises: 53 | ValueError: If either of the provided images is not suitable. 54 | IOError: If either of the image files cannot be found. 55 | 56 | Examples: 57 | >>> img1 = "docs/images/wheel_on_inclined_plane.png" 58 | >>> img2 = "docs/images/wheel_on_inclined_plane.png" 59 | >>> compare_images(img1, img2, 0.001) 60 | """ 61 | actual = os.fspath(actual) 62 | if not os.path.exists(actual): 63 | raise ValueError("Output image %s does not exist." % actual) 64 | if os.stat(actual).st_size == 0: 65 | raise ValueError("Output image file %s is empty." % actual) 66 | 67 | # Convert the image to png 68 | expected = os.fspath(expected) 69 | if not os.path.exists(expected): 70 | raise IOError("Baseline image %r does not exist." % expected) 71 | 72 | # open the image files and remove the alpha channel (if it exists) 73 | expected_image = np.asarray(Image.open(expected).convert("RGB")) 74 | actual_image = np.asarray(Image.open(actual).convert("RGB")) 75 | 76 | diff_image = make_test_filename(actual, "failed-diff") 77 | 78 | if tol <= 0: 79 | if np.array_equal(expected_image, actual_image): 80 | return None 81 | 82 | # convert to signed integers, so that the images can be subtracted without 83 | # overflow 84 | expected_image = expected_image.astype(np.int16) 85 | actual_image = actual_image.astype(np.int16) 86 | 87 | rms = calculate_rms(expected_image, actual_image) 88 | 89 | if rms <= tol: 90 | return None 91 | 92 | save_diff_image(expected, actual, diff_image) 93 | 94 | results = dict( 95 | rms=rms, 96 | expected=str(expected), 97 | actual=str(actual), 98 | diff=str(diff_image), 99 | tol=tol, 100 | ) 101 | 102 | return results 103 | 104 | 105 | def save_diff_image(expected: str, actual: str, output: str): 106 | """Creates a diff image from an expected and actual image. 107 | 108 | Args: 109 | expected : File path of expected image. 110 | actual : File path of actual image. 111 | output : File path to save difference image to. 112 | 113 | Raises: 114 | ImageComparisonFailure: If the images are not compatible 115 | """ 116 | # Drop alpha channels, similarly to compare_images. 117 | expected_image = np.asarray(Image.open(expected).convert("RGB")) 118 | actual_image = np.asarray(Image.open(actual).convert("RGB")) 119 | 120 | expected_image = np.array(expected_image).astype(float) 121 | actual_image = np.array(actual_image).astype(float) 122 | if expected_image.shape != actual_image.shape: 123 | raise ImageComparisonFailure( 124 | "Image sizes do not match expected size: {} " 125 | "actual size {}".format(expected_image.shape, actual_image.shape) 126 | ) 127 | abs_diff_image = np.abs(expected_image - actual_image) 128 | 129 | # expand differences in luminance domain 130 | abs_diff_image *= 255 * 10 131 | save_image_np = np.clip(abs_diff_image, 0, 255).astype(np.uint8) 132 | height, width, depth = save_image_np.shape 133 | 134 | # The PDF renderer doesn't produce an alpha channel, but the 135 | # matplotlib PNG writer requires one, so expand the array 136 | if depth == 3: 137 | with_alpha = np.empty((height, width, 4), dtype=np.uint8) 138 | with_alpha[:, :, 0:3] = save_image_np 139 | save_image_np = with_alpha 140 | 141 | # Hard-code the alpha channel to fully solid 142 | save_image_np[:, :, 3] = 255 143 | 144 | Image.fromarray(save_image_np).save(output, format="png") 145 | -------------------------------------------------------------------------------- /tests/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class ImageComparisonFailure(AssertionError): 2 | """Raise this exception to signal a failure in comparison between two images.""" 3 | -------------------------------------------------------------------------------- /tests/utils/given_inferred.py: -------------------------------------------------------------------------------- 1 | from inspect import getfullargspec 2 | import logging 3 | 4 | from hypothesis import given, infer 5 | from hypothesis.errors import InvalidArgument 6 | 7 | 8 | def given_inferred(func): 9 | ( 10 | args, 11 | varargs, 12 | varkw, 13 | defaults, 14 | kwonlyargs, 15 | kwonlydefaults, 16 | annotations, 17 | ) = getfullargspec(func) 18 | logging.debug(f"{func.__name__} has been annotated with given_inferred.") 19 | 20 | def valid(self=None): 21 | nonlocal func, args, kwonlyargs 22 | # infer can only be applied to keywords, so convert the positionals to kws 23 | newargs = {arg: infer for arg in args if arg != "self"} 24 | kwargs = {kw: infer for kw in kwonlyargs} 25 | args = {**newargs, **kwargs} 26 | if self: 27 | return given(**args)(func)(self) 28 | else: 29 | return given(**args)(func)() 30 | 31 | def invalid(message): 32 | def wrapped_test(*arguments, **kwargs): 33 | raise InvalidArgument(message) 34 | 35 | wrapped_test.is_hypothesis_test = True 36 | return wrapped_test 37 | 38 | if varargs: 39 | return invalid( 40 | ( 41 | "Cannot apply @given_inferred to a function" 42 | "with arbitrary positional arguments." 43 | ) 44 | ) 45 | if varkw: 46 | return invalid( 47 | ( 48 | "Cannot apply @given_inferred to a function" 49 | "with arbitrary keyword arguments" 50 | ) 51 | ) 52 | if defaults or kwonlydefaults: 53 | return invalid("Cannot apply @given_inferred to a function with defaults.") 54 | valid.is_hypothesis_test = True 55 | return valid 56 | -------------------------------------------------------------------------------- /tests/utils/is_close.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | atol = 1e-4 4 | 5 | 6 | def isclose(a: float, b: float): 7 | return np.isclose(a, b, atol=atol) 8 | -------------------------------------------------------------------------------- /tests/utils/new_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rvodden/pysketcher/9dc55a65616cab1e02c91c4a7320cbba4c33d07e/tests/utils/new_image.png -------------------------------------------------------------------------------- /tests/utils/type_strategy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable, get_args, get_origin, get_type_hints, Type, TypeVar 3 | 4 | from hypothesis.strategies import register_type_strategy, SearchStrategy 5 | 6 | RT = TypeVar("RT") 7 | 8 | 9 | class TypeStrategy: 10 | _func: Callable[[], SearchStrategy] 11 | 12 | def __init__(self, typ: Type = None) -> None: 13 | self._type = typ 14 | 15 | def __call__(self, func: Callable[[], SearchStrategy[RT]]) -> SearchStrategy[RT]: 16 | self._func = func 17 | 18 | def wrapper(*args, **kwargs): 19 | logging.debug(f"Calling {self._func.__name__}.") 20 | return self._func(*args, **kwargs) 21 | 22 | if not self._type: 23 | hints = get_type_hints(func) 24 | logging.debug(hints) 25 | if "return" not in hints: 26 | msg = ( 27 | f"Cannot register {self._func.__name__}, " 28 | "as does not have a return type hint." 29 | ) 30 | logging.error(msg) 31 | raise ValueError(msg) 32 | else: 33 | hint = hints["return"] 34 | origin = get_origin(hint) 35 | if origin != SearchStrategy: 36 | msg = ( 37 | f"Cannot register {self._func.__name__}, " 38 | "as does it returns {origin} not a SearchStrategy" 39 | ) 40 | logging.error(msg) 41 | raise ValueError(msg) 42 | else: 43 | self._type = get_args(hint)[0] 44 | logging.debug(f"Registering {self._func.__name__} to {self._type}.") 45 | register_type_strategy(self._type, func) 46 | return wrapper 47 | --------------------------------------------------------------------------------