├── .codeclimate.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── demo_configs │ ├── all_elements.yml │ └── simple_floor_plan.yml └── images │ ├── floor_plan_with_dimensions.png │ ├── floor_plan_without_dimensions.png │ └── individual_elements.png ├── pyproject.toml ├── renovation ├── __init__.py ├── __main__.py ├── constants.py ├── elements │ ├── __init__.py │ ├── basic.py │ ├── electricity.py │ ├── element.py │ ├── info.py │ ├── lighting.py │ ├── multipurpose.py │ └── registry.py ├── floor_plan.py └── project.py ├── requirements.txt └── requirements ├── base.txt └── constraints.txt /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 8 6 | file-lines: 7 | config: 8 | threshold: 500 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # Other editors 163 | *~ 164 | .save 165 | 166 | # Binaries 167 | *.db 168 | *.sqlite3 169 | 170 | # Logs 171 | nohup.out 172 | 173 | # TeX 174 | *.aux 175 | *.bbl 176 | *.blg 177 | *.dvi 178 | *.out 179 | *.pdf 180 | *.gz 181 | 182 | # Project files 183 | configs/ 184 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nikolay Lysenko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maintainability](https://api.codeclimate.com/v1/badges/b48a5bd1bcaac990923a/maintainability)](https://codeclimate.com/github/Nikolay-Lysenko/renovation/maintainability) 2 | [![PyPI version](https://badge.fury.io/py/renovation.svg)](https://pypi.org/project/renovation/) 3 | 4 | # Renovation 5 | 6 | ## Overview 7 | 8 | This is a drawing tool that produces floor plans. It is controlled through YAML config files and has no built-in GUI (only CLI is provided). Although it may look like a drawback, people with technical background may find it more convenient. Compared with drag-and-drop tools, config-based interface simplifies fine-grained control and allows versioning projects with VCSs like Git. 9 | 10 | The below figure demonstrates available elements. 11 | 12 | ![individual_elements.png](https://github.com/Nikolay-Lysenko/renovation/blob/master/docs/images/individual_elements.png) 13 | 14 | Some other elements can be composed of them. For example, in the next section it is shown how to draw ventilation duct and French balcony. 15 | 16 | ## Usage 17 | 18 | To install a stable version, run: 19 | ```bash 20 | pip install renovation 21 | ``` 22 | 23 | To generate floor plans, run: 24 | ```bash 25 | python -m renovation -c /path/to/config.yml 26 | ``` 27 | Here, config in YAML is a custom file where properties of each element to be drawn are set. These properties include location, orientation, size, and so on. 28 | 29 | Let us dive into details. Please look at a [demo example](https://github.com/Nikolay-Lysenko/renovation/blob/master/docs/demo_configs/simple_floor_plan.yml) as a reference while reading further explanations. 30 | 31 | The section named `project` defines properties of output such as: 32 | * Extension (multi-page PDF document, directory with PNG images, or both) 33 | * Location 34 | * DPI (dots-per-inch, resolution) 35 | 36 | In the demo config, only PNG output is requested and one of the generated images is shown below: 37 | 38 | ![floor_plan_with_dimensions.png](https://github.com/Nikolay-Lysenko/renovation/blob/master/docs/images/floor_plan_with_dimensions.png) 39 | 40 | In the section named `default_layout`, below parameters are set for floor plans that do not override them in their `layout` sections: 41 | * Dimensions of area to be drawn (in real-world meters, i.e., meters prior to scaling) 42 | * Scale 43 | * Grid settings 44 | 45 | The section named `reusable_elements` is designed to store arbitrary collections of elements that can be used by individual floor plans. Demo example uses it to define walls, windows, and doors. 46 | 47 | Finally, settings of individual floor plans are listed. These settings might include: 48 | * Title 49 | * Layout 50 | * Names of element collections to reuse 51 | * Extra elements 52 | -------------------------------------------------------------------------------- /docs/demo_configs/all_elements.yml: -------------------------------------------------------------------------------- 1 | project: 2 | dpi: 200 3 | pdf_file: null 4 | png_dir: "docs/images" 5 | default_layout: 6 | bottom_left_corner: [0, 0.4] 7 | top_right_corner: [4, 9] 8 | scale_numerator: 1 9 | scale_denominator: 40 10 | grid_major_step: null 11 | grid_minor_step: null 12 | reusable_elements: 13 | walls_windows_doors: 14 | # Wall 15 | - type: wall 16 | anchor_point: [2.2, 8.8] 17 | length: 1.6 18 | thickness: 0.2 19 | - type: text_box 20 | anchor_point: [1.0, 8.9] 21 | lines: 22 | - "Wall:" 23 | transparency: 0.0 24 | # Window 25 | - type: wall 26 | anchor_point: [2.2, 8.3] 27 | length: 0.3 28 | thickness: 0.2 29 | color: gray 30 | - type: wall 31 | anchor_point: [3.5, 8.3] 32 | length: 0.3 33 | thickness: 0.2 34 | color: gray 35 | - type: window 36 | anchor_point: [2.5, 8.3] 37 | length: 1.0 38 | overall_thickness: 0.2 39 | single_line_thickness: 0.025 40 | - type: text_box 41 | anchor_point: [1.0, 8.4] 42 | lines: 43 | - "Window:" 44 | transparency: 0.0 45 | # Door 46 | - type: wall 47 | anchor_point: [2.2, 7.0] 48 | length: 0.3 49 | thickness: 0.2 50 | color: gray 51 | - type: wall 52 | anchor_point: [3.5, 7.0] 53 | length: 0.3 54 | thickness: 0.2 55 | color: gray 56 | - type: door 57 | anchor_point: [2.5, 7.2] 58 | doorway_width: 1.0 59 | door_width: 0.9 60 | thickness: 0.05 61 | - type: text_box 62 | anchor_point: [1.0, 7.5] 63 | lines: 64 | - "Door:" 65 | transparency: 0.0 66 | # Polygon 67 | - type: wall 68 | anchor_point: [2.94641, 6.0] 69 | length: 0.65339 70 | thickness: 0.2 71 | color: gray 72 | - type: wall 73 | anchor_point: [2.94641, 6.2] 74 | length: 0.65339 75 | thickness: 0.2 76 | orientation_angle: 30 77 | color: gray 78 | - type: polygon 79 | vertices: 80 | - [2.2, 6.0] 81 | - [2.84641, 6.3732051] 82 | - [2.94641, 6.2] 83 | - [2.94641, 6.0] 84 | - type: text_box 85 | anchor_point: [1.0, 6.3] 86 | lines: 87 | - "Polygon:" 88 | transparency: 0.0 89 | # Dimension arrow 90 | - type: dimension_arrow 91 | anchor_point: [2.2, 5.55] 92 | length: 1.6 93 | - type: text_box 94 | anchor_point: [1.0, 5.5] 95 | lines: 96 | - "Dimension arrow:" 97 | transparency: 0.0 98 | # Lines 99 | - type: line 100 | first_point: [2.2, 5.05] 101 | second_point: [3.8, 5.05] 102 | width: 1.0 103 | - type: line 104 | first_point: [2.2, 4.85] 105 | second_point: [3.8, 4.85] 106 | width: 1.0 107 | style: dashed 108 | - type: line 109 | first_point: [2.2, 4.65] 110 | second_point: [3.8, 4.65] 111 | width: 1.0 112 | style: dash_dot 113 | - type: line 114 | first_point: [2.2, 4.45] 115 | second_point: [3.8, 4.45] 116 | width: 1.0 117 | style: dotted 118 | - type: text_box 119 | anchor_point: [1.0, 4.75] 120 | lines: 121 | - "Lines:" 122 | transparency: 0.0 123 | # Text box 124 | - type: text_box 125 | anchor_point: [3.0, 3.9] 126 | lines: 127 | - "Text box." 128 | - "It can be multiline." 129 | - type: text_box 130 | anchor_point: [1.0, 3.9] 131 | lines: 132 | - "Text box:" 133 | transparency: 0.0 134 | # Power outlets 135 | - type: power_outlet 136 | anchor_point: [2.5, 3.05] 137 | length: 0.2 138 | - type: power_outlet 139 | anchor_point: [2.9, 3.05] 140 | length: 0.2 141 | waterproof: true 142 | - type: power_outlet 143 | anchor_point: [3.3, 3.05] 144 | length: 0.2 145 | high_voltage: true 146 | - type: power_outlet 147 | anchor_point: [3.7, 3.05] 148 | length: 0.2 149 | low_current: true 150 | - type: text_box 151 | anchor_point: [1.0, 3.1] 152 | lines: 153 | - "Power outlets" 154 | - "(regular, waterproof," 155 | - "high-voltage, low-current):" 156 | transparency: 0.0 157 | # Electrical cable 158 | - type: electrical_cable 159 | anchor_point: [3.0, 2.4] 160 | symbol_length: 0.2 161 | - type: text_box 162 | anchor_point: [1.0, 2.5] 163 | lines: 164 | - "Electrical cable:" 165 | transparency: 0.0 166 | # Ceiling lamp 167 | - type: ceiling_lamp 168 | anchor_point: [3.0, 2.05] 169 | symbol_diameter: 0.3 170 | line_width: 0.75 171 | - type: text_box 172 | anchor_point: [1.0, 2.05] 173 | lines: 174 | - "Ceiling lamp:" 175 | transparency: 0.0 176 | # Wall lamp 177 | - type: wall_lamp 178 | anchor_point: [3.0, 1.5] 179 | symbol_diameter: 0.2 180 | - type: text_box 181 | anchor_point: [1.0, 1.6] 182 | lines: 183 | - "Wall lamp:" 184 | transparency: 0.0 185 | # LED strip 186 | - type: led_strip 187 | anchor_point: [2.3, 1.1] 188 | length: 1.4 189 | width: 0.1 190 | - type: text_box 191 | anchor_point: [1.0, 1.15] 192 | lines: 193 | - "LED strip:" 194 | transparency: 0.0 195 | # Switches 196 | - type: switch 197 | anchor_point: [2.5, 0.4] 198 | symbol_length: 0.2 199 | - type: switch 200 | anchor_point: [2.9, 0.4] 201 | symbol_length: 0.2 202 | two_key: true 203 | - type: switch 204 | anchor_point: [3.3, 0.4] 205 | symbol_length: 0.2 206 | pass_through: true 207 | - type: switch 208 | anchor_point: [3.7, 0.4] 209 | symbol_length: 0.2 210 | two_key: true 211 | pass_through: true 212 | - type: text_box 213 | anchor_point: [1.0, 0.4] 214 | lines: 215 | - "Switches" 216 | - "(one-key, two-key," 217 | - "one-key pass-through," 218 | - "two-key pass-through):" 219 | transparency: 0.0 220 | floor_plans: 221 | - title: 222 | text: "Individual elements" 223 | font_size: 18 224 | inherited_elements: 225 | - walls_windows_doors 226 | -------------------------------------------------------------------------------- /docs/demo_configs/simple_floor_plan.yml: -------------------------------------------------------------------------------- 1 | project: 2 | dpi: 200 3 | pdf_file: null 4 | png_dir: "docs/images" 5 | default_layout: 6 | bottom_left_corner: [-1, -2] 7 | top_right_corner: [7, 7] 8 | scale_numerator: 1 9 | scale_denominator: 40 10 | grid_major_step: 1 11 | grid_minor_step: 0.1 12 | reusable_elements: 13 | walls_windows_doors: 14 | # 1. BEDROOM 15 | # Leftmost outer wall 16 | - type: wall 17 | anchor_point: [-0.2, 6] 18 | length: 5.58 19 | thickness: 0.2 20 | orientation_angle: 270 21 | # Left part of top outer wall 22 | - type: wall 23 | anchor_point: [-0.2, 6] 24 | length: 0.81 25 | thickness: 0.45 26 | # Window 27 | - type: window 28 | anchor_point: [0.61, 6] 29 | length: 1.65 30 | overall_thickness: 0.45 31 | single_line_thickness: 0.05 32 | # Right part of top outer wall 33 | - type: wall 34 | anchor_point: [2.26, 6] 35 | length: 1.54 36 | thickness: 0.45 37 | # Rightmost internal wall 38 | - type: wall 39 | anchor_point: [3.02, 6] 40 | length: 4.1 41 | thickness: 0.08 42 | orientation_angle: 270 43 | # Wall to the right of door 44 | - type: wall 45 | anchor_point: [2.945, 1.92] 46 | length: 0.075 47 | thickness: 0.08 48 | # Door 49 | - type: door 50 | anchor_point: [2.045, 1.985] 51 | doorway_width: 0.9 52 | door_width: 0.8 53 | thickness: 0.05 54 | color: gray 55 | # Wall to the left of door 56 | - type: wall 57 | anchor_point: [1.97, 1.92] 58 | length: 0.075 59 | thickness: 0.08 60 | # Left wall of appendix 61 | - type: wall 62 | anchor_point: [1.97, 1.92] 63 | length: 0.63 64 | thickness: 0.08 65 | orientation_angle: 90 66 | # Bottom wall 67 | - type: wall 68 | anchor_point: [0, 2.47] 69 | length: 1.97 70 | thickness: 0.08 71 | # 2. BATHROOM 72 | # Duct 73 | - type: wall 74 | anchor_point: [0, 0.64] 75 | length: 1.89 76 | thickness: 0.08 77 | color: gray 78 | # Wall above the door 79 | - type: wall 80 | anchor_point: [1.89, 1.92] 81 | length: 0.15 82 | thickness: 0.08 83 | orientation_angle: 270 84 | # Door 85 | - type: door 86 | anchor_point: [1.905, 0.87] 87 | doorway_width: 0.9 88 | door_width: 0.8 89 | thickness: 0.05 90 | orientation_angle: 90 91 | to_the_right: true 92 | color: gray 93 | # Wall below the door 94 | - type: wall 95 | anchor_point: [1.89, 0.87] 96 | length: 0.45 97 | thickness: 0.08 98 | orientation_angle: 270 99 | # Bottom external wall 100 | - type: wall 101 | anchor_point: [-0.2, 0.22] 102 | length: 2.09 103 | thickness: 0.2 104 | # 3. KITCHEN 105 | # Door 106 | - type: door 107 | anchor_point: [3.035, 1.0] 108 | doorway_width: 0.9 109 | door_width: 0.8 110 | thickness: 0.05 111 | orientation_angle: 90 112 | to_the_right: true 113 | color: gray 114 | # Wall below the door 115 | - type: wall 116 | anchor_point: [3.02, 1.0] 117 | length: 0.07 118 | thickness: 0.08 119 | orientation_angle: 270 120 | # Bottom internal wall 121 | - type: wall 122 | anchor_point: [3.02, 0.85] 123 | length: 1.24 124 | thickness: 0.08 125 | # Bottom external wall 126 | - type: wall 127 | anchor_point: [4.26, 0.73] 128 | length: 1.89 129 | thickness: 0.2 130 | # Right wall 131 | - type: wall 132 | anchor_point: [6.35, 0.73] 133 | length: 5.27 134 | thickness: 0.2 135 | orientation_angle: 90 136 | # French balcony: Window 137 | - type: window 138 | anchor_point: [3.8, 6.19] 139 | length: 1.65 140 | overall_thickness: 0.075 141 | single_line_thickness: 0.02 142 | # French balcony: Left door 143 | - type: door 144 | anchor_point: [3.8, 6.265] 145 | doorway_width: 0.825 146 | door_width: 0.75 147 | thickness: 0.075 148 | to_the_right: true 149 | color: gray 150 | # French balcony: Right door 151 | - type: door 152 | anchor_point: [5.45, 6.19] 153 | doorway_width: 0.825 154 | door_width: 0.75 155 | thickness: 0.075 156 | orientation_angle: 180 157 | color: gray 158 | # French balcony: Left wall 159 | - type: wall 160 | anchor_point: [3.56, 6.45] 161 | length: 0.505 162 | thickness: 0.05 163 | orientation_angle: 90 164 | color: gray 165 | # French balcony: Long wall 166 | - type: wall 167 | anchor_point: [3.56, 6.905] 168 | length: 2.13 169 | thickness: 0.05 170 | color: gray 171 | # French balcony: Right wall 172 | - type: wall 173 | anchor_point: [5.69, 6.955] 174 | length: 0.505 175 | thickness: 0.05 176 | orientation_angle: 270 177 | color: gray 178 | # Wall to the right of balcony 179 | - type: wall 180 | anchor_point: [5.45, 6] 181 | length: 0.9 182 | thickness: 0.45 183 | # 4. HALL 184 | # Leftmost external wall 185 | - type: wall 186 | anchor_point: [1.77, 0.42] 187 | length: 0.8 188 | thickness: 0.2 189 | orientation_angle: 270 190 | # Rightmost external wall 191 | - type: wall 192 | anchor_point: [4.26, 0.73] 193 | length: 1.11 194 | thickness: 0.2 195 | orientation_angle: 270 196 | # Wall to the left of entrance door 197 | - type: wall 198 | anchor_point: [1.77, -0.58] 199 | length: 0.8 200 | thickness: 0.2 201 | # Entrance door 202 | - type: door 203 | anchor_point: [2.57, -0.48] 204 | doorway_width: 1.0 205 | door_width: 0.9 206 | thickness: 0.1 207 | to_the_right: true 208 | # Wall to the right of entrance door 209 | - type: wall 210 | anchor_point: [3.57, -0.58] 211 | length: 0.89 212 | thickness: 0.2 213 | floor_plans: 214 | - title: 215 | text: "Floor plan without dimensions" 216 | font_size: 18 217 | inherited_elements: 218 | - walls_windows_doors 219 | - title: 220 | text: "Floor plan with dimensions" 221 | font_size: 18 222 | inherited_elements: 223 | - walls_windows_doors 224 | elements: 225 | # 1. BEDROOM 226 | # Leftmost outer wall 227 | - type: dimension_arrow 228 | anchor_point: [0.1, 2.55] 229 | length: 3.45 230 | orientation_angle: 90 231 | # Left part of top outer wall 232 | - type: dimension_arrow 233 | anchor_point: [0, 5.9] 234 | length: 0.61 235 | # Window 236 | - type: dimension_arrow 237 | anchor_point: [0.61, 5.9] 238 | length: 1.65 239 | # Right part of top outer wall 240 | - type: dimension_arrow 241 | anchor_point: [2.26, 5.9] 242 | length: 0.76 243 | # Rightmost internal wall 244 | - type: dimension_arrow 245 | anchor_point: [2.92, 2] 246 | length: 4.00 247 | orientation_angle: 90 248 | annotate_above: true 249 | # Appendix width 250 | - type: dimension_arrow 251 | anchor_point: [1.97, 2.65] 252 | length: 1.05 253 | # Appendix length 254 | - type: dimension_arrow 255 | anchor_point: [2.2, 2] 256 | length: 0.55 257 | orientation_angle: 90 258 | # Bottom wall 259 | - type: dimension_arrow 260 | anchor_point: [0, 2.65] 261 | length: 1.97 262 | annotate_above: true 263 | # 2. BATHROOM 264 | # External wall 265 | - type: dimension_arrow 266 | anchor_point: [0.1, 0.72] 267 | length: 1.75 268 | orientation_angle: 90 269 | # Upper wall 270 | - type: dimension_arrow 271 | anchor_point: [0, 2.37] 272 | length: 1.89 273 | # Wall above the door 274 | - type: dimension_arrow 275 | anchor_point: [1.79, 1.77] 276 | length: 0.7 277 | orientation_angle: 90 278 | annotate_above: true 279 | # Door 280 | - type: dimension_arrow 281 | anchor_point: [1.79, 0.87] 282 | length: 0.9 283 | orientation_angle: 90 284 | annotate_above: true 285 | # Wall below the door 286 | - type: dimension_arrow 287 | anchor_point: [1.79, 0.72] 288 | length: 0.15 289 | orientation_angle: 90 290 | annotate_above: true 291 | font_size: 9 292 | tip_length: 0.075 293 | # 3. KITCHEN 294 | # Wall above the door 295 | - type: dimension_arrow 296 | anchor_point: [3.2, 1.9] 297 | length: 4.1 298 | orientation_angle: 90 299 | # Door 300 | - type: dimension_arrow 301 | anchor_point: [3.2, 1] 302 | length: 0.9 303 | orientation_angle: 90 304 | # Bottom wall 305 | - type: dimension_arrow 306 | anchor_point: [3.1, 1.025] 307 | length: 3.05 308 | annotate_above: true 309 | # Right wall 310 | - type: dimension_arrow 311 | anchor_point: [6.05, 0.93] 312 | length: 5.07 313 | orientation_angle: 90 314 | annotate_above: true 315 | # Wall to the left of balcony 316 | - type: dimension_arrow 317 | anchor_point: [3.1, 5.9] 318 | length: 0.7 319 | # French balcony 320 | - type: dimension_arrow 321 | anchor_point: [3.8, 5.4] 322 | length: 1.65 323 | # Wall to the right of balcony 324 | - type: dimension_arrow 325 | anchor_point: [5.45, 5.9] 326 | length: 0.7 327 | # 4. HALL 328 | # Wall to the left of entrance door 329 | - type: dimension_arrow 330 | anchor_point: [1.97, -0.28] 331 | length: 0.6 332 | annotate_above: true 333 | # Entrance door 334 | - type: dimension_arrow 335 | anchor_point: [2.57, -0.28] 336 | length: 1.0 337 | annotate_above: true 338 | # Wall to the right of entrance door 339 | - type: dimension_arrow 340 | anchor_point: [3.57, -0.28] 341 | length: 0.69 342 | annotate_above: true 343 | # Rightmost wall 344 | - type: dimension_arrow 345 | anchor_point: [4.16, -0.38] 346 | length: 1.23 347 | orientation_angle: 90 348 | annotate_above: true 349 | # Bottom part of leftmost wall 350 | - type: dimension_arrow 351 | anchor_point: [2.07, -0.38] 352 | length: 1.22 353 | orientation_angle: 90 354 | # Wall with door to kitchen 355 | - type: dimension_arrow 356 | anchor_point: [2.95, 0.85] 357 | length: 1.07 358 | orientation_angle: 90 359 | annotate_above: true 360 | # Upper wall 361 | - type: dimension_arrow 362 | anchor_point: [3.02, 0.75] 363 | length: 1.24 364 | -------------------------------------------------------------------------------- /docs/images/floor_plan_with_dimensions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikolay-Lysenko/renovation/15a5a0f807b0989160fe1f22234288ccc8b5e0e4/docs/images/floor_plan_with_dimensions.png -------------------------------------------------------------------------------- /docs/images/floor_plan_without_dimensions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikolay-Lysenko/renovation/15a5a0f807b0989160fe1f22234288ccc8b5e0e4/docs/images/floor_plan_without_dimensions.png -------------------------------------------------------------------------------- /docs/images/individual_elements.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikolay-Lysenko/renovation/15a5a0f807b0989160fe1f22234288ccc8b5e0e4/docs/images/individual_elements.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "renovation" 7 | version = "0.1.1" 8 | description = "Drawing tool that produces floor plans needed to renovate an apartment" 9 | readme = "README.md" 10 | keywords = ["apartments", "drawing", "floor_plan", "floor_plans"] 11 | urls = {Homepage = "https://github.com/Nikolay-Lysenko/renovation"} 12 | authors = [{name = "Nikolay Lysenko", email = "nikolay-lysenco@yandex.ru"}] 13 | classifiers = [ 14 | "Development Status :: 4 - Beta", 15 | "Intended Audience :: End Users/Desktop", 16 | "Topic :: Multimedia :: Graphics", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3" 19 | ] 20 | requires-python = ">= 3.8" 21 | dependencies = [ 22 | "matplotlib", 23 | "numpy", 24 | "PyYAML", 25 | ] 26 | 27 | [tool.setuptools] 28 | packages = [ 29 | "renovation", 30 | "renovation.elements" 31 | ] 32 | -------------------------------------------------------------------------------- /renovation/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw floor plans needed to renovate an apartment. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | -------------------------------------------------------------------------------- /renovation/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Render project. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import argparse 9 | 10 | import yaml 11 | 12 | from renovation.elements import create_elements_registry 13 | from renovation.floor_plan import FloorPlan 14 | from renovation.project import Project 15 | 16 | 17 | def parse_cli_args() -> argparse.Namespace: 18 | """ 19 | Parse arguments passed via Command Line Interface (CLI). 20 | 21 | :return: 22 | namespace with arguments 23 | """ 24 | parser = argparse.ArgumentParser(description='Rendering of renovation project floor plans.') 25 | parser.add_argument( 26 | '-c', '--config_path', type=str, default=None, help='path to configuration file' 27 | ) 28 | cli_args = parser.parse_args() 29 | return cli_args 30 | 31 | 32 | def main() -> None: 33 | """Parse CLI arguments and run requested tasks.""" 34 | cli_args = parse_cli_args() 35 | config_path = cli_args.config_path 36 | with open(config_path) as config_file: 37 | settings = yaml.load(config_file, Loader=yaml.FullLoader) 38 | elements_registry = create_elements_registry() 39 | 40 | floor_plans = [] 41 | for floor_plan_params in settings['floor_plans']: 42 | layout_params = floor_plan_params.get('layout') or settings['default_layout'] 43 | floor_plan = FloorPlan(**layout_params) 44 | title_params = floor_plan_params.get('title') 45 | if title_params: 46 | floor_plan.add_title(**title_params) 47 | for set_name in floor_plan_params.get('inherited_elements', []): 48 | for element_params in settings['reusable_elements'].get(set_name, []): 49 | element_class = elements_registry[element_params['type']] 50 | element_params = {k: v for k, v in element_params.items() if k != 'type'} 51 | floor_plan.add_element(element_class(**element_params)) 52 | for element_params in floor_plan_params.get('elements', []): 53 | element_class = elements_registry[element_params.pop('type')] 54 | floor_plan.add_element(element_class(**element_params)) 55 | floor_plans.append(floor_plan) 56 | 57 | project = Project(floor_plans, settings['project']['dpi']) 58 | pdf_path = settings['project'].get('pdf_file') 59 | if pdf_path is not None: 60 | project.render_to_pdf(pdf_path) 61 | png_path = settings['project'].get('png_dir') 62 | if png_path is not None: 63 | project.render_to_png(png_path) 64 | 65 | 66 | if __name__ == '__main__': 67 | main() 68 | -------------------------------------------------------------------------------- /renovation/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Store global constants. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | METERS_PER_INCH = 0.0254 9 | RIGHT_ANGLE_IN_DEGREES = 90 10 | -------------------------------------------------------------------------------- /renovation/elements/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw floor plan elements. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | from .basic import Door, Wall, Window 9 | from .electricity import ElectricalCable, PowerOutlet 10 | from .element import Element 11 | from .info import DimensionArrow, TextBox 12 | from .lighting import CeilingLamp, LEDStrip, WallLamp, Switch 13 | from .multipurpose import Line, Polygon 14 | from .registry import create_elements_registry 15 | 16 | 17 | ___all__ = [ 18 | 'CeilingLamp', 19 | 'DimensionArrow', 20 | 'Door', 21 | 'ElectricalCable', 22 | 'Element', 23 | 'LEDStrip', 24 | 'Line', 25 | 'Polygon', 26 | 'PowerOutlet', 27 | 'Switch', 28 | 'TextBox', 29 | 'Wall', 30 | 'WallLamp', 31 | 'Window', 32 | 'create_elements_registry' 33 | ] 34 | -------------------------------------------------------------------------------- /renovation/elements/basic.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw basic elements that represent walls, windows, and doors. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import math 9 | 10 | import matplotlib.axes 11 | from matplotlib.patches import Arc, Rectangle 12 | 13 | from renovation.constants import RIGHT_ANGLE_IN_DEGREES 14 | from .element import Element 15 | 16 | 17 | class Wall(Element): 18 | """ 19 | Straight wall. 20 | 21 | For corners of acute or obtuse angles please consider `renovation.elements.Polygon` class. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | anchor_point: tuple[float, float], 27 | length: float, 28 | thickness: float, 29 | orientation_angle: float = 0, 30 | color: str = 'black' 31 | ): 32 | """ 33 | Initialize an instance. 34 | 35 | :param anchor_point: 36 | coordinates of anchor point (in meters); 37 | bottom left point is anchor point if `orientation_angle == 0` 38 | :param length: 39 | length of the wall (in meters) 40 | :param thickness: 41 | thickness of the wall (in meters) 42 | :param orientation_angle: 43 | angle (in degrees) that specifies orientation of the wall; 44 | it is measured between X-axis and the wall in positive direction (counterclockwise); 45 | initial wall is rotated around anchor point to get the desired orientation 46 | :param color: 47 | color to use for drawing the wall 48 | :return: 49 | freshly created instance of `Wall` class 50 | """ 51 | self.anchor_point = anchor_point 52 | self.length = length 53 | self.thickness = thickness 54 | self.orientation_angle = orientation_angle 55 | self.color = color 56 | 57 | def draw(self, ax: matplotlib.axes.Axes) -> None: 58 | """Draw straight wall.""" 59 | patch = Rectangle( 60 | self.anchor_point, 61 | self.length, 62 | self.thickness, 63 | angle=self.orientation_angle, 64 | facecolor=self.color 65 | ) 66 | ax.add_patch(patch) 67 | 68 | 69 | class Window(Element): 70 | """Window in a wall.""" 71 | 72 | def __init__( 73 | self, 74 | anchor_point: tuple[float, float], 75 | length: float, 76 | overall_thickness: float, 77 | single_line_thickness: float, 78 | orientation_angle: float = 0, 79 | color: str = 'black', 80 | ): 81 | """ 82 | Initialize an instance. 83 | 84 | :param anchor_point: 85 | coordinates (in meters) of anchor point; 86 | bottom left point is anchor point if `orientation_angle == 0` 87 | :param length: 88 | length of the window (in meters) 89 | :param overall_thickness: 90 | total thickness of the window (in meters) 91 | :param single_line_thickness: 92 | thickness of a single outer line forming window (in meters) 93 | :param orientation_angle: 94 | angle (in degrees) that specifies orientation of the window; 95 | it is measured between X-axis and the window in positive direction (counterclockwise); 96 | initial window is rotated around anchor point to get the desired orientation 97 | :param color: 98 | color to use for drawing the window 99 | :return: 100 | freshly created instance of `Window` class 101 | """ 102 | internal_thickness = overall_thickness - 2 * single_line_thickness 103 | if internal_thickness <= 0: 104 | raise ValueError("Window can not be drawn due to invalid thicknesses.") 105 | 106 | self.anchor_point = anchor_point 107 | self.length = length 108 | self.overall_thickness = overall_thickness 109 | self.single_line_thickness = single_line_thickness 110 | self.orientation_angle = orientation_angle 111 | self.color = color 112 | 113 | def draw(self, ax: matplotlib.axes.Axes) -> None: 114 | """Draw window.""" 115 | first_line = Rectangle( 116 | self.anchor_point, 117 | self.length, 118 | self.single_line_thickness, 119 | angle=self.orientation_angle, 120 | facecolor=self.color 121 | ) 122 | ax.add_patch(first_line) 123 | 124 | orthogonal_angle_in_rad = math.radians(self.orientation_angle + RIGHT_ANGLE_IN_DEGREES) 125 | shift = self.overall_thickness - self.single_line_thickness 126 | second_anchor_point = ( 127 | self.anchor_point[0] + math.cos(orthogonal_angle_in_rad) * shift, 128 | self.anchor_point[1] + math.sin(orthogonal_angle_in_rad) * shift 129 | ) 130 | second_line = Rectangle( 131 | second_anchor_point, 132 | self.length, 133 | self.single_line_thickness, 134 | angle=self.orientation_angle, 135 | facecolor=self.color 136 | ) 137 | ax.add_patch(second_line) 138 | 139 | 140 | class Door(Element): 141 | """Single door.""" 142 | 143 | def __init__( 144 | self, 145 | anchor_point: tuple[float, float], 146 | doorway_width: float, 147 | door_width: float, 148 | thickness: float, 149 | orientation_angle: float = 0, 150 | to_the_right: bool = False, 151 | color: str = 'black' 152 | ): 153 | """ 154 | Initialize an instance. 155 | 156 | :param anchor_point: 157 | coordinates (in meters) of anchor point; here, it is the door frame corner 158 | that is on the same side with hinges and is on the side where the door opens 159 | (given `to_the_right` is set to `False`, else it is on the opposite side) 160 | :param doorway_width: 161 | width of the doorway (in meters), i.e. width of the door itself 162 | plus the width of both sides of the door frame 163 | :param door_width: 164 | width of the door itself (in meters) 165 | :param thickness: 166 | thickness of the door (in meters) 167 | :param orientation_angle: 168 | angle (in degrees) that specifies orientation of the doorway; 169 | it is measured between X-axis and the doorway in positive direction (counterclockwise); 170 | initial doorway is rotated around anchor point to get the desired orientation 171 | :param to_the_right: 172 | binary indicator whether the door opens to the right if someone looks at it 173 | from the hinges point along the doorway 174 | :param color: 175 | color to use for drawing the window 176 | :return: 177 | freshly created instance of `Door` class 178 | """ 179 | self.anchor_point = anchor_point 180 | self.doorway_width = doorway_width 181 | self.door_width = door_width 182 | self.frame_width = (doorway_width - door_width) / 2 183 | self.thickness = thickness 184 | self.orientation_angle = orientation_angle 185 | self.to_the_right = to_the_right 186 | self.color = color 187 | 188 | def draw(self, ax: matplotlib.axes.Axes) -> None: 189 | """Draw the door, its opening trajectory, and the door frame.""" 190 | orientation_angle_in_rad = math.radians(self.orientation_angle) 191 | 192 | frame_orientation_angle = self.orientation_angle - RIGHT_ANGLE_IN_DEGREES 193 | frame_with_hinges = Rectangle( 194 | self.anchor_point, 195 | self.thickness, 196 | self.frame_width, 197 | angle=frame_orientation_angle, 198 | facecolor=self.color 199 | ) 200 | ax.add_patch(frame_with_hinges) 201 | 202 | shift = self.frame_width + self.door_width 203 | frame_without_hinges_anchor_point = ( 204 | self.anchor_point[0] + math.cos(orientation_angle_in_rad) * shift, 205 | self.anchor_point[1] + math.sin(orientation_angle_in_rad) * shift 206 | ) 207 | frame_without_hinges = Rectangle( 208 | frame_without_hinges_anchor_point, 209 | self.thickness, 210 | self.frame_width, 211 | angle=frame_orientation_angle, 212 | facecolor=self.color 213 | ) 214 | ax.add_patch(frame_without_hinges) 215 | 216 | hinges_point = ( 217 | self.anchor_point[0] + math.cos(orientation_angle_in_rad) * self.frame_width, 218 | self.anchor_point[1] + math.sin(orientation_angle_in_rad) * self.frame_width 219 | ) 220 | if self.to_the_right: 221 | hinges_point = ( 222 | hinges_point[0] + math.sin(orientation_angle_in_rad) * self.thickness, 223 | hinges_point[1] - math.cos(orientation_angle_in_rad) * self.thickness 224 | ) 225 | door = Rectangle( 226 | hinges_point, 227 | self.door_width, 228 | self.thickness, 229 | angle=self.orientation_angle - RIGHT_ANGLE_IN_DEGREES, 230 | facecolor=self.color 231 | ) 232 | else: 233 | door = Rectangle( 234 | hinges_point, 235 | self.thickness, 236 | self.door_width, 237 | angle=self.orientation_angle, 238 | facecolor=self.color 239 | ) 240 | ax.add_patch(door) 241 | 242 | arc_anchor_point = ( 243 | hinges_point[0] + math.cos(orientation_angle_in_rad) * self.thickness, 244 | hinges_point[1] + math.sin(orientation_angle_in_rad) * self.thickness 245 | ) 246 | extra_degrees_for_smooth_connection = 2 247 | if self.to_the_right: 248 | start_angle = -RIGHT_ANGLE_IN_DEGREES - extra_degrees_for_smooth_connection 249 | end_angle = 0 250 | else: 251 | start_angle = 0 252 | end_angle = RIGHT_ANGLE_IN_DEGREES + extra_degrees_for_smooth_connection 253 | arc = Arc( 254 | arc_anchor_point, 255 | 2 * (self.door_width - self.thickness), 256 | 2 * self.door_width, 257 | angle=self.orientation_angle, 258 | theta1=start_angle, 259 | theta2=end_angle, 260 | color=self.color, 261 | linewidth=1 262 | ) 263 | ax.add_patch(arc) 264 | -------------------------------------------------------------------------------- /renovation/elements/electricity.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw elements representing electricity-related objects. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import math 9 | 10 | import matplotlib.axes 11 | from matplotlib.patches import Arc, Circle 12 | 13 | from renovation.constants import RIGHT_ANGLE_IN_DEGREES 14 | from .element import Element 15 | 16 | 17 | class PowerOutlet(Element): 18 | """Power outlet.""" 19 | 20 | def __init__( 21 | self, 22 | anchor_point: tuple[float, float], 23 | length: float, 24 | orientation_angle: float = 0, 25 | waterproof: bool = False, 26 | high_voltage: bool = False, 27 | low_current: bool = False, 28 | line_width: float = 0.5, 29 | color: str = 'black' 30 | ): 31 | """ 32 | Initialize an instance. 33 | 34 | :param anchor_point: 35 | coordinates (in meters) of anchor point; 36 | the center of the segment shared with a wall is anchor point 37 | :param length: 38 | length of the power outlet (in meters) 39 | :param orientation_angle: 40 | angle (in degrees) that specifies orientation of the power outlet; 41 | it is measured between X-axis and the outlet in positive direction (counterclockwise); 42 | initial outlet is rotated around anchor point to get the desired orientation 43 | :param waterproof: 44 | indicator whether the power outlet is waterproof 45 | :param high_voltage: 46 | indicator whether the power outlet has 380-400V instead of regular 220-230V 47 | :param low_current: 48 | indicator whether the power outlet is designed for low-current appliances 49 | (e.g., Wi-Fi router); also it can be used for Ethernet outlets and so on 50 | :param line_width: 51 | width of lines for `matplotlib` 52 | :param color: 53 | color to use for drawing the power outlet 54 | :return: 55 | freshly created instance of `PowerOutlet` class 56 | """ 57 | self.anchor_point = anchor_point 58 | self.length = length 59 | self.orientation_angle = orientation_angle 60 | self.waterproof = waterproof 61 | self.high_voltage = high_voltage 62 | self.low_current = low_current 63 | self.line_width = line_width 64 | self.color = color 65 | 66 | def draw(self, ax: matplotlib.axes.Axes) -> None: 67 | """Draw power outlet.""" 68 | arc = Arc( 69 | self.anchor_point, 70 | self.length, 71 | self.length, 72 | theta1=self.orientation_angle, 73 | theta2=self.orientation_angle + 2 * RIGHT_ANGLE_IN_DEGREES, 74 | lw=self.line_width, 75 | color=self.color 76 | ) 77 | ax.add_patch(arc) 78 | 79 | half_length = 0.5 * self.length 80 | tip_angle_in_radians = math.radians(self.orientation_angle + RIGHT_ANGLE_IN_DEGREES) 81 | arc_middle = ( 82 | self.anchor_point[0] + half_length * math.cos(tip_angle_in_radians), 83 | self.anchor_point[1] + half_length * math.sin(tip_angle_in_radians) 84 | ) 85 | ax.plot( 86 | [self.anchor_point[0], arc_middle[0]], 87 | [self.anchor_point[1], arc_middle[1]], 88 | lw=self.line_width, 89 | color=self.color 90 | ) 91 | 92 | orientation_angle_in_radians = math.radians(self.orientation_angle) 93 | bar_left_end = ( 94 | arc_middle[0] - half_length * math.cos(orientation_angle_in_radians), 95 | arc_middle[1] - half_length * math.sin(orientation_angle_in_radians) 96 | ) 97 | bar_right_end = ( 98 | arc_middle[0] + half_length * math.cos(orientation_angle_in_radians), 99 | arc_middle[1] + half_length * math.sin(orientation_angle_in_radians) 100 | ) 101 | ax.plot( 102 | [bar_left_end[0], bar_right_end[0]], 103 | [bar_left_end[1], bar_right_end[1]], 104 | lw=self.line_width, 105 | color=self.color 106 | ) 107 | 108 | tip_end = ( 109 | self.anchor_point[0] + self.length * math.cos(tip_angle_in_radians), 110 | self.anchor_point[1] + self.length * math.sin(tip_angle_in_radians) 111 | ) 112 | ax.plot( 113 | [arc_middle[0], tip_end[0]], 114 | [arc_middle[1], tip_end[1]], 115 | lw=self.line_width, 116 | color=self.color 117 | ) 118 | 119 | if self.waterproof: 120 | angle_in_degrees = self.orientation_angle + 1.5 * RIGHT_ANGLE_IN_DEGREES 121 | angle_in_radians = math.radians(angle_in_degrees) 122 | radius_end = ( 123 | self.anchor_point[0] + half_length * math.cos(angle_in_radians), 124 | self.anchor_point[1] + half_length * math.sin(angle_in_radians) 125 | ) 126 | ax.plot( 127 | [self.anchor_point[0], radius_end[0]], 128 | [self.anchor_point[1], radius_end[1]], 129 | lw=self.line_width, 130 | color=self.color 131 | ) 132 | 133 | if self.high_voltage: 134 | left_tip_end = ( 135 | tip_end[0] - 0.5 * half_length * math.cos(orientation_angle_in_radians), 136 | tip_end[1] - 0.5 * half_length * math.sin(orientation_angle_in_radians) 137 | ) 138 | right_tip_end = ( 139 | tip_end[0] + 0.5 * half_length * math.cos(orientation_angle_in_radians), 140 | tip_end[1] + 0.5 * half_length * math.sin(orientation_angle_in_radians) 141 | ) 142 | ax.plot( 143 | [arc_middle[0], left_tip_end[0]], 144 | [arc_middle[1], left_tip_end[1]], 145 | lw=self.line_width, 146 | color=self.color 147 | ) 148 | ax.plot( 149 | [arc_middle[0], right_tip_end[0]], 150 | [arc_middle[1], right_tip_end[1]], 151 | lw=self.line_width, 152 | color=self.color 153 | ) 154 | 155 | if self.low_current: 156 | circle = Circle( 157 | tip_end, 158 | 0.25 * self.length, 159 | fill=False, 160 | lw=self.line_width, 161 | edgecolor=self.color 162 | ) 163 | ax.add_patch(circle) 164 | 165 | 166 | class ElectricalCable(Element): 167 | """Electrical cable for direct power supply without any outlets.""" 168 | 169 | def __init__( 170 | self, 171 | anchor_point: tuple[float, float], 172 | symbol_length: float, 173 | orientation_angle: float = 0, 174 | n_arcs: int = 4, 175 | line_width: float = 0.5, 176 | color: str = 'black' 177 | ): 178 | """ 179 | Initialize an instance. 180 | 181 | :param anchor_point: 182 | coordinates (in meters) of anchor point; 183 | the center of the segment shared with a wall is anchor point 184 | :param symbol_length: 185 | length of the symbol, not of the real cable 186 | :param orientation_angle: 187 | angle (in degrees) that specifies orientation of the electrical cable; 188 | it is measured between X-axis and the symbol in positive direction (counterclockwise); 189 | initial symbol is rotated around anchor point to get the desired orientation 190 | :param n_arcs: 191 | number of turns representing a curved cable 192 | :param line_width: 193 | width of lines for `matplotlib` 194 | :param color: 195 | color to use for drawing the power outlet 196 | :return: 197 | freshly created instance of `ElectricalCable` class 198 | """ 199 | self.anchor_point = anchor_point 200 | self.symbol_length = symbol_length 201 | self.orientation_angle = orientation_angle 202 | self.n_arcs = n_arcs 203 | self.line_width = line_width 204 | self.color = color 205 | 206 | def draw(self, ax: matplotlib.axes.Axes) -> None: 207 | """Draw electrical cable.""" 208 | radius = self.symbol_length / (2 * (self.n_arcs + 1)) 209 | tip_angle_in_radians = math.radians(self.orientation_angle + RIGHT_ANGLE_IN_DEGREES) 210 | circle_center = ( 211 | self.anchor_point[0] + radius * math.cos(tip_angle_in_radians), 212 | self.anchor_point[1] + radius * math.sin(tip_angle_in_radians) 213 | ) 214 | circle = Circle( 215 | circle_center, radius, fill=True, facecolor=self.color, edgecolor=self.color, lw=0.1 216 | ) 217 | ax.add_patch(circle) 218 | 219 | for i in range(self.n_arcs): 220 | arc_center = ( 221 | self.anchor_point[0] + (3 + 2 * i) * radius * math.cos(tip_angle_in_radians), 222 | self.anchor_point[1] + (3 + 2 * i) * radius * math.sin(tip_angle_in_radians) 223 | ) 224 | arc = Arc( 225 | arc_center, 226 | 2 * radius, 227 | 2 * radius, 228 | theta1=self.orientation_angle + (2 * (i % 2) - 1) * RIGHT_ANGLE_IN_DEGREES, 229 | theta2=self.orientation_angle + (2 * (i % 2) + 1) * RIGHT_ANGLE_IN_DEGREES, 230 | lw=self.line_width, 231 | color=self.color 232 | ) 233 | ax.add_patch(arc) 234 | -------------------------------------------------------------------------------- /renovation/elements/element.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define abstract element. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | from abc import ABC, abstractmethod 9 | 10 | import matplotlib.axes 11 | 12 | 13 | class Element(ABC): 14 | """Abstract element.""" 15 | 16 | @abstractmethod 17 | def draw(self, ax: matplotlib.axes.Axes) -> None: 18 | """Draw the element.""" 19 | pass 20 | -------------------------------------------------------------------------------- /renovation/elements/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw elements that do not represent any real objects, but provide info about them. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import math 9 | 10 | import matplotlib.axes 11 | import numpy as np 12 | from matplotlib.patches import Polygon 13 | 14 | from .element import Element 15 | 16 | 17 | class DimensionArrow(Element): 18 | """Dimension arrow.""" 19 | 20 | def __init__( 21 | self, 22 | anchor_point: tuple[float, float], 23 | length: float, 24 | orientation_angle: float = 0, 25 | width: int = 0.01, 26 | tip_length: float = 0.1, 27 | font_size: int = 10, 28 | annotate_above: bool = False, 29 | color: str = 'black', 30 | ): 31 | """ 32 | Initialize an instance. 33 | 34 | :param anchor_point: 35 | coordinates (in meters) of anchor point; 36 | the leftmost point is anchor point if `orientation_angle == 0` 37 | :param length: 38 | length of the wall (in meters); this value is also placed to annotation 39 | :param orientation_angle: 40 | angle (in degrees) that specifies orientation of the arrow; 41 | it is measured between X-axis and the arrow in positive direction (counterclockwise); 42 | initial arrow is rotated around anchor point to get the desired orientation 43 | :param width: 44 | width of lines (in meters before scaling) 45 | :param tip_length: 46 | length of single arrow tip (in meters before scaling) 47 | :param font_size: 48 | font size 49 | :param annotate_above: 50 | if it is set to `True`, annotation is placed above the arrow (prior to its rotation) 51 | :param color: 52 | color to use for drawing the arrow and its annotation 53 | :return: 54 | freshly created instance of `DimensionArrow` class 55 | """ 56 | self.anchor_point = anchor_point 57 | self.length = length 58 | self.orientation_angle = orientation_angle 59 | self.width = width 60 | self.tip_length = tip_length 61 | self.font_size = font_size 62 | self.annotate_above = annotate_above 63 | self.color = color 64 | 65 | def draw(self, ax: matplotlib.axes.Axes) -> None: 66 | """Draw dimension arrow.""" 67 | tip_angle = math.radians(30) 68 | initial_vertices = np.array([ 69 | [0, 0], 70 | [ 71 | self.tip_length - math.sin(tip_angle) * self.width, 72 | math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 73 | ], 74 | [ 75 | self.tip_length, 76 | math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 77 | - math.cos(tip_angle) * self.width 78 | ], 79 | [ 80 | self.width / 2 / math.tan(tip_angle) + self.width / math.sin(tip_angle), 81 | self.width / 2 82 | ], 83 | [ 84 | self.length 85 | - self.width / 2 / math.tan(tip_angle) - self.width / math.sin(tip_angle), 86 | self.width / 2 87 | ], 88 | [ 89 | self.length - self.tip_length, 90 | math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 91 | - math.cos(tip_angle) * self.width 92 | ], 93 | [ 94 | self.length - self.tip_length + math.sin(tip_angle) * self.width, 95 | math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 96 | ], 97 | [self.length, 0], 98 | [ 99 | self.length - self.tip_length + math.sin(tip_angle) * self.width, 100 | -math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 101 | ], 102 | [ 103 | self.length - self.tip_length, 104 | -math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 105 | + math.cos(tip_angle) * self.width 106 | ], 107 | [ 108 | self.length 109 | - self.width / 2 / math.tan(tip_angle) - self.width / math.sin(tip_angle), 110 | -self.width / 2 111 | ], 112 | [ 113 | self.width / 2 / math.tan(tip_angle) + self.width / math.sin(tip_angle), 114 | -self.width / 2 115 | ], 116 | [ 117 | self.tip_length, 118 | -math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 119 | + math.cos(tip_angle) * self.width 120 | ], 121 | [ 122 | self.tip_length - math.sin(tip_angle) * self.width, 123 | -math.tan(tip_angle) * (self.tip_length - math.sin(tip_angle) * self.width) 124 | ], 125 | ]) 126 | rotation_angle = math.radians(self.orientation_angle) 127 | rotation_matrix = np.array([ 128 | [math.cos(rotation_angle), -math.sin(rotation_angle)], 129 | [math.sin(rotation_angle), math.cos(rotation_angle)], 130 | ]) 131 | rotated_vertices = np.dot(rotation_matrix, initial_vertices.T).T 132 | shift_vector = np.array([[self.anchor_point[0], self.anchor_point[1]]]) 133 | vertices = rotated_vertices + shift_vector 134 | arrow = Polygon(vertices, facecolor=self.color) 135 | ax.add_patch(arrow) 136 | 137 | initial_text_center = [ 138 | self.length / 2, 139 | (self.font_size * 0.0125 if self.annotate_above else -self.font_size * 0.0125) 140 | ] 141 | text_anchor_point = np.dot(rotation_matrix, np.array([initial_text_center]).T).T 142 | text_anchor_point += shift_vector 143 | text_anchor_x = text_anchor_point[0][0].item() 144 | text_anchor_y = text_anchor_point[0][1].item() 145 | text = str(self.length) 146 | ax.text( 147 | text_anchor_x, text_anchor_y, text, 148 | verticalalignment='center', horizontalalignment='center', 149 | rotation=self.orientation_angle, color=self.color, fontsize=self.font_size 150 | ) 151 | 152 | 153 | class TextBox(Element): 154 | """Text box.""" 155 | 156 | def __init__( 157 | self, 158 | anchor_point: tuple[float, float], 159 | lines: list[str], 160 | font_size: int = 10, 161 | color: str = 'black', 162 | transparency: float = 0.75, 163 | ): 164 | """ 165 | Initialize an instance. 166 | 167 | :param anchor_point: 168 | coordinates (in meters) of anchor point; the center of a text box is its anchor point 169 | :param lines: 170 | text lines to be printed 171 | :param font_size: 172 | font size 173 | :param color: 174 | color to use for drawing the text and the bounding box 175 | :param transparency: 176 | transparency of the bounding box 177 | """ 178 | self.anchor_point = anchor_point 179 | self.lines = lines 180 | self.font_size = font_size 181 | self.color = color 182 | self.transparency = transparency 183 | 184 | def draw(self, ax: matplotlib.axes.Axes) -> None: 185 | """Draw text box.""" 186 | ax.text( 187 | self.anchor_point[0], 188 | self.anchor_point[1], 189 | '\n'.join(self.lines), 190 | verticalalignment='center', 191 | horizontalalignment='center', 192 | color = self.color, 193 | fontsize = self.font_size, 194 | bbox={'boxstyle': 'round', 'facecolor': 'white', 'alpha': self.transparency} 195 | ) 196 | -------------------------------------------------------------------------------- /renovation/elements/lighting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw elements representing lighting-related objects. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import math 9 | 10 | import matplotlib.axes 11 | from matplotlib.patches import Arc, Circle, Rectangle 12 | 13 | from renovation.constants import RIGHT_ANGLE_IN_DEGREES 14 | from .element import Element 15 | 16 | 17 | class CeilingLamp(Element): 18 | """Ceiling lamp represented by a circle and a cross inside.""" 19 | 20 | def __init__( 21 | self, 22 | anchor_point: tuple[float, float], 23 | symbol_diameter: float, 24 | line_width: float = 0.5, 25 | color: str = 'black' 26 | ): 27 | """ 28 | Initialize an instance. 29 | 30 | :param anchor_point: 31 | coordinates (in meters) of anchor point; here, it is the center of the symbol 32 | :param symbol_diameter: 33 | diameter of the symbol (in meters) 34 | :param line_width: 35 | width of lines for `matplotlib` 36 | :param color: 37 | color to use for drawing the lamp 38 | :return: 39 | freshly created instance of `CeilingLamp` class 40 | """ 41 | self.anchor_point = anchor_point 42 | self.symbol_diameter = symbol_diameter 43 | self.line_width = line_width 44 | self.color = color 45 | 46 | def draw(self, ax: matplotlib.axes.Axes) -> None: 47 | """Draw ceiling lamp.""" 48 | radius = 0.5 * self.symbol_diameter 49 | circle = Circle( 50 | self.anchor_point, 51 | radius, 52 | fill=False, 53 | lw=self.line_width, 54 | edgecolor=self.color 55 | ) 56 | ax.add_patch(circle) 57 | 58 | ax.plot( 59 | [ 60 | self.anchor_point[0] - 0.5 * math.sqrt(2) * radius, 61 | self.anchor_point[0] + 0.5 * math.sqrt(2) * radius 62 | ], 63 | [ 64 | self.anchor_point[1] - 0.5 * math.sqrt(2) * radius, 65 | self.anchor_point[1] + 0.5 * math.sqrt(2) * radius 66 | ], 67 | lw=self.line_width, 68 | color=self.color 69 | ) 70 | ax.plot( 71 | [ 72 | self.anchor_point[0] - 0.5 * math.sqrt(2) * radius, 73 | self.anchor_point[0] + 0.5 * math.sqrt(2) * radius 74 | ], 75 | [ 76 | self.anchor_point[1] + 0.5 * math.sqrt(2) * radius, 77 | self.anchor_point[1] - 0.5 * math.sqrt(2) * radius 78 | ], 79 | lw=self.line_width, 80 | color=self.color 81 | ) 82 | 83 | 84 | class WallLamp(Element): 85 | """Wall lamp (e.g., sconce).""" 86 | 87 | def __init__( 88 | self, 89 | anchor_point: tuple[float, float], 90 | symbol_diameter: float, 91 | orientation_angle: float = 0.0, 92 | stub_relative_depth: float = 0.3, 93 | line_width: float = 0.5, 94 | color: str = 'black' 95 | ): 96 | """ 97 | Initialize an instance. 98 | 99 | :param anchor_point: 100 | coordinates (in meters) of anchor point; 101 | here, it is the center of wall connection segment 102 | :param symbol_diameter: 103 | diameter of the symbol (in meters) 104 | :param orientation_angle: 105 | angle (in degrees) that specifies orientation of the lamp; 106 | it is measured between X-axis and the lamp in positive direction (counterclockwise); 107 | initial lamp is rotated around anchor point to get the desired orientation 108 | :param stub_relative_depth: 109 | ratio of stub depth to its width 110 | :param line_width: 111 | width of lines for `matplotlib` 112 | :param color: 113 | color to use for drawing the lamp 114 | :return: 115 | freshly created instance of `WallLamp` class 116 | """ 117 | self.anchor_point = anchor_point 118 | self.symbol_diameter = symbol_diameter 119 | self.orientation_angle = orientation_angle 120 | self.stub_relative_depth = stub_relative_depth 121 | self.line_width = line_width 122 | self.color = color 123 | 124 | def draw(self, ax: matplotlib.axes.Axes) -> None: 125 | """Draw wall lamp.""" 126 | orientation_angle_in_radians = math.radians(self.orientation_angle) 127 | stub_width = 0.5 * math.sqrt(2) * self.symbol_diameter 128 | stub_depth = self.stub_relative_depth * stub_width 129 | stub_anchor_point = ( 130 | self.anchor_point[0] - 0.5 * math.cos(orientation_angle_in_radians) * stub_width, 131 | self.anchor_point[1] - 0.5 * math.sin(orientation_angle_in_radians) * stub_width 132 | ) 133 | stub = Rectangle( 134 | stub_anchor_point, 135 | stub_width, 136 | stub_depth, 137 | angle=self.orientation_angle, 138 | fill=False, 139 | lw=self.line_width, 140 | edgecolor=self.color 141 | ) 142 | ax.add_patch(stub) 143 | 144 | orthogonal_angle_in_radians = orientation_angle_in_radians + math.pi / 2 145 | shift = stub_depth + 0.5 * stub_width 146 | arc_center = ( 147 | self.anchor_point[0] + math.cos(orthogonal_angle_in_radians) * shift, 148 | self.anchor_point[1] + math.sin(orthogonal_angle_in_radians) * shift 149 | ) 150 | arc = Arc( 151 | arc_center, 152 | self.symbol_diameter, 153 | self.symbol_diameter, 154 | theta1=self.orientation_angle - 0.5 * RIGHT_ANGLE_IN_DEGREES, 155 | theta2=self.orientation_angle + 2.5 * RIGHT_ANGLE_IN_DEGREES, 156 | lw=self.line_width, 157 | color=self.color 158 | ) 159 | ax.add_patch(arc) 160 | 161 | cross_angles = [ 162 | orientation_angle_in_radians - 0.75 * math.pi, 163 | orientation_angle_in_radians + 0.25 * math.pi, 164 | orientation_angle_in_radians + 0.75 * math.pi, 165 | orientation_angle_in_radians - 0.25 * math.pi 166 | ] 167 | ax.plot( 168 | [ 169 | arc_center[0] + 0.5 * math.cos(cross_angles[0]) * self.symbol_diameter, 170 | arc_center[0] + 0.5 * math.cos(cross_angles[1]) * self.symbol_diameter 171 | ], 172 | [ 173 | arc_center[1] + 0.5 * math.sin(cross_angles[0]) * self.symbol_diameter, 174 | arc_center[1] + 0.5 * math.sin(cross_angles[1]) * self.symbol_diameter 175 | ], 176 | lw=self.line_width, 177 | color=self.color 178 | ) 179 | ax.plot( 180 | [ 181 | arc_center[0] + 0.5 * math.cos(cross_angles[2]) * self.symbol_diameter, 182 | arc_center[0] + 0.5 * math.cos(cross_angles[3]) * self.symbol_diameter 183 | ], 184 | [ 185 | arc_center[1] + 0.5 * math.sin(cross_angles[2]) * self.symbol_diameter, 186 | arc_center[1] + 0.5 * math.sin(cross_angles[3]) * self.symbol_diameter 187 | ], 188 | lw=self.line_width, 189 | color=self.color 190 | ) 191 | 192 | 193 | class LEDStrip(Element): 194 | """LED strip.""" 195 | 196 | def __init__( 197 | self, 198 | anchor_point: tuple[float, float], 199 | length: float, 200 | width: float, 201 | orientation_angle: float = 0.0, 202 | line_width: float = 0.5, 203 | color: str = 'black' 204 | ): 205 | """ 206 | Initialize an instance. 207 | 208 | :param anchor_point: 209 | coordinates (in meters) of anchor point; here, it is the bottom left corner 210 | :param length: 211 | length of the strip (in meters) 212 | :param width: 213 | width of the strip (in meters) 214 | :param orientation_angle: 215 | angle (in degrees) that specifies orientation of the strip; 216 | it is measured between X-axis and the strip in positive direction (counterclockwise); 217 | initial strip is rotated around anchor point to get the desired orientation 218 | :param line_width: 219 | width of lines for `matplotlib` 220 | :param color: 221 | color to use for drawing the strip 222 | :return: 223 | freshly created instance of `LEDStrip` class 224 | """ 225 | self.anchor_point = anchor_point 226 | self.length = length 227 | self.width = width 228 | self.orientation_angle = orientation_angle 229 | self.line_width = line_width 230 | self.color = color 231 | self.circle_diameter_to_width = 0.6 232 | 233 | def draw(self, ax: matplotlib.axes.Axes) -> None: 234 | """Draw LED strip.""" 235 | rectangle = Rectangle( 236 | self.anchor_point, 237 | self.length, 238 | self.width, 239 | angle=self.orientation_angle, 240 | fill=False, 241 | edgecolor=self.color, 242 | lw=self.line_width 243 | ) 244 | ax.add_patch(rectangle) 245 | 246 | orientation_angle_in_radians = math.radians(self.orientation_angle) 247 | n_circles = math.floor(self.length / self.width) 248 | x_offset = 0.5 * self.length / n_circles 249 | y_offset = 0.5 * self.width 250 | for i in range(n_circles): 251 | circle_center = ( 252 | self.anchor_point[0] 253 | + math.cos(orientation_angle_in_radians) * (2 * i + 1) * x_offset 254 | + math.cos(orientation_angle_in_radians + math.pi / 2) * y_offset, 255 | self.anchor_point[1] 256 | + math.sin(orientation_angle_in_radians) * (2 * i + 1) * x_offset 257 | + math.sin(orientation_angle_in_radians + math.pi / 2) * y_offset 258 | ) 259 | circle = Circle( 260 | circle_center, 261 | 0.5 * self.circle_diameter_to_width * self.width, 262 | fill=False, 263 | lw=self.line_width, 264 | edgecolor=self.color 265 | ) 266 | ax.add_patch(circle) 267 | 268 | 269 | class Switch(Element): 270 | """Lighting switch.""" 271 | 272 | def __init__( 273 | self, 274 | anchor_point: tuple[float, float], 275 | symbol_length: float, 276 | orientation_angle: float = 0, 277 | two_key: bool = False, 278 | pass_through: bool = False, 279 | line_width: float = 0.5, 280 | color: str = 'black' 281 | ): 282 | """ 283 | Initialize an instance. 284 | 285 | :param anchor_point: 286 | coordinates (in meters) of anchor point; 287 | the point shared with a wall is the anchor point 288 | :param symbol_length: 289 | length of the symbol, not of the real switch 290 | :param orientation_angle: 291 | angle (in degrees) that specifies orientation of the switch; 292 | it is measured between X-axis and the symbol in positive direction (counterclockwise); 293 | initial symbol is rotated around anchor point to get the desired orientation 294 | :param two_key: 295 | binary indicator whether the switch has two keys 296 | :param pass_through: 297 | binary indicator whether there are other switches controlling the same lamp (or lamps) 298 | :param line_width: 299 | width of lines for `matplotlib` 300 | :param color: 301 | color to use for drawing the switch 302 | :return: 303 | freshly created instance of `Switch` class 304 | """ 305 | self.anchor_point = anchor_point 306 | self.symbol_length = symbol_length 307 | self.orientation_angle = orientation_angle 308 | self.two_key = two_key 309 | self.pass_through = pass_through 310 | self.line_width = line_width 311 | self.color = color 312 | 313 | def __draw_key_symbol__( 314 | self, 315 | ax: matplotlib.axes.Axes, 316 | circle_center: tuple[float, float], 317 | radius: float, 318 | key_angle_in_degrees: float 319 | ) -> None: 320 | """Draw key symbol.""" 321 | key_angle_in_radians = math.radians(key_angle_in_degrees) 322 | orthogonal_angle_in_radians = key_angle_in_radians - math.pi / 2 323 | 324 | key_corner = ( 325 | circle_center[0] + 3 * radius * math.cos(key_angle_in_radians), 326 | circle_center[1] + 3 * radius * math.sin(key_angle_in_radians) 327 | ) 328 | ax.plot( 329 | [circle_center[0], key_corner[0]], 330 | [circle_center[1], key_corner[1]], 331 | lw=self.line_width, 332 | color=self.color 333 | ) 334 | key_tip = ( 335 | key_corner[0] + 4 / 3 * radius * math.cos(orthogonal_angle_in_radians), 336 | key_corner[1] + 4 / 3 * radius * math.sin(orthogonal_angle_in_radians) 337 | ) 338 | ax.plot( 339 | [key_corner[0], key_tip[0]], 340 | [key_corner[1], key_tip[1]], 341 | lw=self.line_width, 342 | color=self.color 343 | ) 344 | if self.pass_through: 345 | middle_point = ( 346 | circle_center[0] + 2 * radius * math.cos(key_angle_in_radians), 347 | circle_center[1] + 2 * radius * math.sin(key_angle_in_radians) 348 | ) 349 | second_tip_end = ( 350 | middle_point[0] + 2 / 3 * radius * math.cos(orthogonal_angle_in_radians), 351 | middle_point[1] + 2 / 3 * radius * math.sin(orthogonal_angle_in_radians) 352 | ) 353 | ax.plot( 354 | [middle_point[0], second_tip_end[0]], 355 | [middle_point[1], second_tip_end[1]], 356 | lw=self.line_width, 357 | color=self.color 358 | ) 359 | 360 | def draw(self, ax: matplotlib.axes.Axes) -> None: 361 | """Draw switch.""" 362 | radius = self.symbol_length / 4 363 | tip_angle_in_radians = math.radians(self.orientation_angle + RIGHT_ANGLE_IN_DEGREES) 364 | 365 | circle_center = ( 366 | self.anchor_point[0] + radius * math.cos(tip_angle_in_radians), 367 | self.anchor_point[1] + radius * math.sin(tip_angle_in_radians) 368 | ) 369 | circle = Circle( 370 | circle_center, radius, fill=True, facecolor=self.color, edgecolor=self.color, lw=0.1 371 | ) 372 | ax.add_patch(circle) 373 | 374 | key_angle = self.orientation_angle + RIGHT_ANGLE_IN_DEGREES 375 | self.__draw_key_symbol__(ax, circle_center, radius, key_angle) 376 | if self.two_key: 377 | key_angle = self.orientation_angle + RIGHT_ANGLE_IN_DEGREES / 2 378 | self.__draw_key_symbol__(ax, circle_center, radius, key_angle) 379 | -------------------------------------------------------------------------------- /renovation/elements/multipurpose.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw universal elements that have context-dependent meaning. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import matplotlib.axes 9 | from matplotlib import patches 10 | 11 | from .element import Element 12 | 13 | 14 | class Line(Element): 15 | """Line (solid, dashed, or dotted)""" 16 | 17 | style_to_matplotlib_code = {'solid': '-', 'dashed': '--', 'dotted': ':', 'dash_dot': '-.'} 18 | 19 | def __init__( 20 | self, 21 | first_point: tuple[float, float], 22 | second_point: tuple[float, float], 23 | width: float = 0.5, 24 | style: str = 'solid', 25 | color: str = 'black' 26 | ): 27 | """ 28 | Initialize an instance. 29 | 30 | :param first_point: 31 | coordinates of the first end (in meters) 32 | :param second_point: 33 | coordinates of the second end (in meters) 34 | :param width: 35 | width of the line for `matplotlib` 36 | :param style: 37 | type of line ('solid', 'dashed', 'dotted', or 'dash_dot') 38 | :param color: 39 | color to use for drawing the line 40 | """ 41 | self.first_point = first_point 42 | self.second_point = second_point 43 | self.width = width 44 | self.style = self.style_to_matplotlib_code[style] 45 | self.color = color 46 | 47 | def draw(self, ax: matplotlib.axes.Axes) -> None: 48 | """Draw line.""" 49 | ax.plot( 50 | [self.first_point[0], self.second_point[0]], 51 | [self.first_point[1], self.second_point[1]], 52 | lw=self.width, 53 | ls=self.style, 54 | color=self.color 55 | ) 56 | 57 | 58 | class Polygon(Element): 59 | """Polygon. In particular, it can be used for wall corners or acute or obtuse angles.""" 60 | 61 | def __init__( 62 | self, 63 | vertices: list[tuple[float, float]], 64 | line_width: float = 0.1, 65 | color: str = 'black' 66 | ): 67 | """ 68 | Initialize an instance. 69 | 70 | :param vertices: 71 | list of vertices coordinates (in meters) 72 | :param line_width: 73 | width of lines for `matplotlib` 74 | :param color: 75 | color to use for drawing the line 76 | :return: 77 | freshly created instance of `Polygon` class 78 | """ 79 | self.vertices = vertices 80 | self.line_width = line_width 81 | self.color = color 82 | 83 | def draw(self, ax: matplotlib.axes.Axes) -> None: 84 | """Draw polygon""" 85 | polygon = patches.Polygon( 86 | self.vertices, 87 | lw=self.line_width, 88 | fill=True, 89 | facecolor=self.color, 90 | edgecolor=self.color 91 | ) 92 | ax.add_patch(polygon) 93 | -------------------------------------------------------------------------------- /renovation/elements/registry.py: -------------------------------------------------------------------------------- 1 | """ 2 | Map element names to their classes. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | from .basic import Door, Wall, Window 9 | from .electricity import ElectricalCable, PowerOutlet 10 | from .element import Element 11 | from .info import DimensionArrow, TextBox 12 | from .lighting import CeilingLamp, LEDStrip, Switch, WallLamp 13 | from .multipurpose import Line, Polygon 14 | 15 | 16 | def create_elements_registry() -> dict[str, type(Element)]: 17 | """ 18 | Create registry of implemented elements. 19 | 20 | :return: 21 | mapping from element type to element class 22 | """ 23 | registry = { 24 | 'ceiling_lamp': CeilingLamp, 25 | 'dimension_arrow': DimensionArrow, 26 | 'door': Door, 27 | 'electrical_cable': ElectricalCable, 28 | 'led_strip': LEDStrip, 29 | 'line': Line, 30 | 'polygon': Polygon, 31 | 'power_outlet': PowerOutlet, 32 | 'switch': Switch, 33 | 'text_box': TextBox, 34 | 'wall': Wall, 35 | 'wall_lamp': WallLamp, 36 | 'window': Window, 37 | } 38 | return registry 39 | -------------------------------------------------------------------------------- /renovation/floor_plan.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw single floor plan. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | from typing import Optional 9 | 10 | import matplotlib.pyplot as plt 11 | import numpy as np 12 | 13 | from renovation.constants import METERS_PER_INCH 14 | from renovation.elements import Element 15 | 16 | 17 | class FloorPlan: 18 | """Floor plan.""" 19 | 20 | def __init__( 21 | self, 22 | bottom_left_corner: tuple[float, float], 23 | top_right_corner: tuple[float, float], 24 | scale_numerator: int = 1, 25 | scale_denominator: int = 100, 26 | grid_major_step: Optional[float] = None, 27 | grid_minor_step: Optional[float] = None, 28 | ): 29 | """ 30 | Initialize an instance. 31 | 32 | :param bottom_left_corner: 33 | coordinates (in meters before scaling) of the bottom left corner of the floor plan 34 | :param top_right_corner: 35 | coordinates (in meters before scaling) of the top right corner of the floor plan 36 | :param scale_numerator: 37 | scale numerator 38 | :param scale_denominator: 39 | scale denominator 40 | :param grid_major_step: 41 | step (in meters before scaling) between major grid lines 42 | :param grid_minor_step: 43 | step (in meters before scaling) between minor grid lines 44 | :return: 45 | freshly created instance of `FloorPlan` class 46 | """ 47 | horizontal_len = top_right_corner[0] - bottom_left_corner[0] 48 | vertical_len = top_right_corner[1] - bottom_left_corner[1] 49 | scale = scale_numerator / scale_denominator 50 | fig_width = horizontal_len * scale / METERS_PER_INCH 51 | fig_height = vertical_len * scale / METERS_PER_INCH 52 | 53 | fig = plt.figure(figsize=(fig_width, fig_height)) 54 | ax = fig.add_subplot(111) 55 | ax.set_aspect('equal') 56 | ax.tick_params( 57 | which='both', 58 | left=False, right=False, bottom=False, top=False, 59 | labelleft=False, labelright=False, labelbottom=False, labeltop=False, 60 | ) 61 | ax.spines['left'].set_visible(False) 62 | ax.spines['right'].set_visible(False) 63 | ax.spines['bottom'].set_visible(False) 64 | ax.spines['top'].set_visible(False) 65 | 66 | if grid_major_step is not None: 67 | major_xticks = np.arange( 68 | bottom_left_corner[0], 69 | top_right_corner[0] + 0.5 * grid_major_step, 70 | grid_major_step 71 | ) 72 | major_yticks = np.arange( 73 | bottom_left_corner[1], 74 | top_right_corner[1] + 0.5 * grid_major_step, 75 | grid_major_step 76 | ) 77 | ax.set_xticks(major_xticks) 78 | ax.set_yticks(major_yticks) 79 | ax.grid(which='major', color='orange', alpha=0.25) 80 | if grid_minor_step is not None: 81 | minor_xticks = np.arange( 82 | bottom_left_corner[0], 83 | top_right_corner[0] + 0.5 * grid_minor_step, 84 | grid_minor_step 85 | ) 86 | minor_yticks = np.arange( 87 | bottom_left_corner[1], 88 | top_right_corner[1] + 0.5 * grid_minor_step, 89 | grid_minor_step 90 | ) 91 | ax.set_xticks(minor_xticks, minor=True) 92 | ax.set_yticks(minor_yticks, minor=True) 93 | ax.grid(which='minor', color='orange', alpha=0.1) 94 | 95 | ax.set_xlim(bottom_left_corner[0], top_right_corner[0]) 96 | ax.set_ylim(bottom_left_corner[1], top_right_corner[1]) 97 | 98 | self.fig = fig 99 | self.ax = ax 100 | self.title = None 101 | 102 | def add_title( 103 | self, text: str, font_size: int, rel_x: float = 0.5, rel_y: float = 0.95, **kwargs 104 | ) -> None: 105 | """ 106 | Add title to the floor plan. 107 | 108 | :param text: 109 | title text 110 | :param font_size: 111 | font size 112 | :param rel_x: 113 | relative horizontal position of the text center; 114 | a float between 0 and 1; 115 | value of 0 corresponds to the left edge and value of 1 corresponds to the right edge 116 | :param rel_y: 117 | relative vertical position of the text center; 118 | a float between 0 and 1; 119 | value of 0 corresponds to the bottom edge and value of 1 corresponds to the top edge 120 | :return: 121 | None 122 | """ 123 | self.fig.text( 124 | rel_x, rel_y, text, 125 | horizontalalignment='center', transform=self.fig.transFigure, size=font_size, **kwargs 126 | ) 127 | self.title = text 128 | 129 | def add_element(self, element: Element) -> None: 130 | """ 131 | Add element. 132 | 133 | :param element: 134 | element to be added 135 | :return: 136 | None 137 | """ 138 | element.draw(self.ax) 139 | -------------------------------------------------------------------------------- /renovation/project.py: -------------------------------------------------------------------------------- 1 | """ 2 | Combine all floor plans. 3 | 4 | Author: Nikolay Lysenko 5 | """ 6 | 7 | 8 | import os 9 | from typing import Literal, Union 10 | 11 | from matplotlib.backends.backend_pdf import PdfPages 12 | 13 | from renovation.floor_plan import FloorPlan 14 | 15 | 16 | class Project: 17 | """Collection of floor plans.""" 18 | 19 | def __init__( 20 | self, 21 | floor_plans: list[FloorPlan], 22 | dpi: Union[float, Literal["figure"]] = "figure" 23 | ): 24 | """ 25 | Initialize an instance. 26 | 27 | :param floor_plans: 28 | floor plans 29 | :param dpi: 30 | DPI (dots-per-inch), number of pixels per inch of space in output file 31 | :return: 32 | freshly created instance of `Project` class 33 | """ 34 | self.floor_plans = floor_plans 35 | self.dpi = dpi 36 | 37 | def render_to_pdf(self, output_path: str) -> None: 38 | """ 39 | Render floor plans to single PDF file with each floor plan on its page. 40 | 41 | :param output_path: 42 | path to output file 43 | :return: 44 | None 45 | """ 46 | with PdfPages(output_path) as pdf: 47 | for floor_plan in self.floor_plans: 48 | pdf.savefig(floor_plan.fig, dpi=self.dpi) 49 | 50 | def render_to_png(self, output_dir: str) -> None: 51 | """ 52 | Render floor plans to separate PNG files from the same directory. 53 | 54 | :param output_dir: 55 | path to output directory 56 | :return: 57 | None 58 | """ 59 | if not os.path.exists(output_dir): 60 | os.mkdir(output_dir) 61 | for i, floor_plan in enumerate(self.floor_plans): 62 | title = floor_plan.title or f"{i}.png" 63 | if not title.endswith('png'): 64 | title += '.png' 65 | floor_plan.fig.savefig(os.path.join(output_dir, title), dpi=self.dpi) 66 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -r requirements/base.txt 2 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | -c constraints.txt 2 | matplotlib==3.10.0 3 | numpy==2.2.1 4 | PyYAML==6.0.2 5 | -------------------------------------------------------------------------------- /requirements/constraints.txt: -------------------------------------------------------------------------------- 1 | certifi==2024.12.14 2 | charset-normalizer==3.4.1 3 | colorclass==2.2.2 4 | contourpy==1.3.1 5 | cycler==0.12.1 6 | docopt==0.6.2 7 | fonttools==4.55.3 8 | idna==3.10 9 | kiwisolver==1.4.8 10 | matplotlib==3.10.0 11 | numpy==2.2.1 12 | packaging==24.2 13 | Pillow==11.1.0 14 | pip-upgrader==1.4.15 15 | pyparsing==3.2.1 16 | python-dateutil==2.9.0 17 | PyYAML==6.0.2 18 | -e git+ssh://git@github.com/Nikolay-Lysenko/renovation.git@8c2b9b8bfafeed263830ac259d59875183e2de33#egg=renovation 19 | requests==2.32.3 20 | six==1.17.0 21 | terminaltables==3.1.10 22 | urllib3==2.3.0 23 | --------------------------------------------------------------------------------