├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── example.py ├── gcode_generator ├── __init__.py ├── fdm_material.py ├── gcode_generator.py ├── gcode_writer │ ├── __init__.py │ ├── griffin_writer.py │ └── ufp_writer.py ├── machine.py ├── plugins │ ├── README.md │ ├── __init__.py │ └── patterns.py ├── thumbnail.png └── vector.py ├── pyproject.toml ├── requirements.txt └── tests └── test_plugins.py /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 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 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | *.gcode 132 | *.ufp 133 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include gcode_generator/thumbnail.png 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GCodeGenerator 2 | Python library to generate gcode for Ultimaker 3D printers. 3 | 4 | :warning: This repository is only for this library, not scripts that use this to create gcode. Store those scripts in your project specific repositories. 5 | You can install this library using `pip install git+https://github.com/Ultimaker/GCodeGenerator.git` 6 | 7 | ```python 8 | from gcode_generator import GCodeGenerator 9 | gcode = GCodeGenerator('Ultimaker S7', layer_height=0.15) 10 | gcode.create_tool('AA 0.4', 'generic_pla') 11 | 12 | gcode.move(100, 100, 0.15) 13 | gcode.extrude(120, 120) # Extrudes a diagonal line from (100,100) to (120,120) 14 | ... 15 | gcode.save('test.gcode') # Or 'test.ufp' 16 | ``` 17 | 18 | ## Material profiles 19 | This generator uses Ultimaker fdm_materials profile files to automatically use the print settings for the selected material. The settings currently used are: 20 | 21 | | setting | default | unit | 22 | |-----------------------|---------|------| 23 | | `print speed` | 70 | mm/s | 24 | | `travel speed` | 150 | mm/s | 25 | | `retraction amount` | 4.5 | mm | 26 | | `retraction speed` | 45 | mm/s | 27 | | `print cooling` | 100 | % | 28 | | `print temperature` | 200 | °C | 29 | | `standby temperature` | 100 | °C | 30 | 31 | For settings that are not specified in the fdm_material file, the above default values are used. 32 | Print profile settings can be overridden for each extruder separately using 33 | `generator.tools[0].material['print speed'] = 45` 34 | 35 | A local `.xml.fdm_material` file can be used by specifying its file name/location. 36 | A profile directly from the [GitHub repository](https://github.com/Ultimaker/fdm_materials) can also be used by specifying e.g. `git:ultimaker_pla_magenta` as the material name. 37 | 38 | ## File formats 39 | The gcode can be saved using the `GCodeGenerator.save(file, **kwargs)` method. 40 | `file` can either be a string (filename) or an open file-like object. 41 | Currently, saving as `.gcode` or `.ufp` are supported. Additionally, you can specify the following keyword arguments: 42 | - `format`: [`gcode`|`ufp`] File format to save as. If not specified, automatically inferred from filename. 43 | - `time_estimate`: number of seconds the print takes. If not specified, an automatic estimate is used. 44 | - `image`: Image to include as thumbnail (UFP only) 45 | - `name`: Name for the object in the metadata json (UFP only) 46 | 47 | ## Plugins 48 | To keep the code organized, the generator only has basic commands built in. Functions for more complex patterns can be added to the plugins folder. 49 | When a script needs these functions, it can import that module. This makes these functions available in the `GCodeGenerator.plugins` namespace. 50 | A function defined as: 51 | ```python 52 | @GeneratorPlugin 53 | def my_special_function(gcode: "GCodeGenerator", x, y, a, b): 54 | ... 55 | ``` 56 | can then be used as: 57 | ```python 58 | gcode.plugins.my_special_function(x, y, a, b) 59 | ``` 60 | See `gcode_generator/plugins/patterns.py` for more examples. 61 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from gcode_generator import GCodeGenerator, Axis, Vector3 3 | 4 | gcode = GCodeGenerator('Ultimaker S7', layer_height = 0.15) 5 | gcode.create_tool('AA 0.4', 'git:generic_pla') 6 | # gcode.create_tool('AA 0.4', 'git:generic_pla', offset=Vector3(22, 0, 0)) 7 | # gcode.select_tool(0) 8 | 9 | centerx, centery = 150, 100 10 | radius = 25 11 | 12 | gcode.move(10, 10) 13 | gcode.prime() 14 | gcode.retract() 15 | gcode.move(z=10, f=25) 16 | 17 | gcode.move(centerx, centery, f=45) 18 | gcode.writeline() 19 | 20 | gcode.move(x=centerx + radius, y=centery, z=gcode.layer_height) 21 | gcode.unretract() 22 | for layer in range(100): 23 | gcode.mark_layer() 24 | gcode.move(z=(layer+1) * gcode.layer_height, f=10) 25 | gcode.move(centerx+radius, centery) 26 | 27 | for angle in np.linspace(0, 2 * np.pi, 100): 28 | x = np.cos(angle) * radius + centerx 29 | y = np.sin(angle) * radius + centery 30 | gcode.extrude(x, y) 31 | 32 | gcode.save('test.ufp') 33 | -------------------------------------------------------------------------------- /gcode_generator/__init__.py: -------------------------------------------------------------------------------- 1 | from .vector import Axis, Vector, Transform, TransformManager 2 | from .machine import Hotend, Material, PrimeStrategy, NozzleOffset, Tool 3 | from .gcode_generator import GCodeGenerator, GCodeWarning 4 | -------------------------------------------------------------------------------- /gcode_generator/fdm_material.py: -------------------------------------------------------------------------------- 1 | import re 2 | import urllib.request 3 | import xml.etree.ElementTree 4 | from typing import Union, TextIO, Optional 5 | 6 | 7 | class FDMElement(xml.etree.ElementTree.Element): 8 | def __init__(self, tag, attrib): 9 | match = re.match(r'\{.*}(.*)', tag) 10 | if match is not None: 11 | tag = match.group(1) 12 | super().__init__(tag, attrib) 13 | 14 | 15 | class FDMReader: 16 | """ 17 | Class to read fdm_material files. 18 | Can read local files, or download a profile from GitHub using 'git:generic_pla[@commit_id]' 19 | """ 20 | EXTENSION = '.xml.fdm_material' 21 | 22 | def __init__(self, file: Union[str, TextIO]): 23 | if isinstance(file, str): 24 | if file.startswith('git:'): 25 | file = file.removeprefix('git:') 26 | commit = 'master' 27 | if '@' in file: 28 | file, commit = file.split('@', 1) 29 | file = self.assert_suffix(file, self.EXTENSION) 30 | self.data = self.download_material(file, commit) 31 | else: 32 | file = self.assert_suffix(file, self.EXTENSION) 33 | with open(file, 'rb') as f: 34 | self.data = f.read() 35 | self.filename = file 36 | else: 37 | self.data = file.read() 38 | if isinstance(self.data, str): 39 | self.data = self.data.encode('utf-8') 40 | self.filename: Optional[str] = None 41 | for attr in ['name', 'file', 'filename']: 42 | self.filename = getattr(file, attr, None) 43 | if isinstance(self.filename, str) and self.filename: 44 | self.filename = self.assert_suffix(self.filename, self.EXTENSION) 45 | break 46 | 47 | builder = xml.etree.ElementTree.TreeBuilder(element_factory=FDMElement) 48 | parser = xml.etree.ElementTree.XMLParser(target=builder) 49 | self.material = xml.etree.ElementTree.fromstring(self.data, parser=parser) 50 | 51 | def getroot(self): 52 | return self.material 53 | 54 | def __bytes__(self): 55 | return self.data 56 | 57 | @staticmethod 58 | def assert_suffix(string: str, suffix: str): 59 | if not string.endswith(suffix): 60 | string = string + suffix 61 | return string 62 | 63 | @classmethod 64 | def download_material(cls, filename, commit='master'): 65 | filename = cls.assert_suffix(filename, cls.EXTENSION) 66 | url = f'https://raw.githubusercontent.com/Ultimaker/fdm_materials/{commit}/{filename}' 67 | with urllib.request.urlopen(url) as file: 68 | return file.read() 69 | -------------------------------------------------------------------------------- /gcode_generator/gcode_generator.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | import re 4 | import enum 5 | import math 6 | import typing 7 | import tempfile 8 | import warnings 9 | from typing import Any, Union, Optional, Literal, TextIO 10 | 11 | import numpy as np 12 | 13 | from .machine import Tool, PrimeStrategy, NozzleOffset, ToolManager 14 | from .vector import Vector, Axis, TransformManager, Transform 15 | from .fdm_material import FDMReader 16 | from .gcode_writer import GCodeWriter 17 | from .plugins import GeneratorPlugin 18 | 19 | 20 | class GCodeWarning(RuntimeWarning): 21 | ... 22 | 23 | 24 | class Arc: 25 | CW = 'CW' 26 | CCW = 'CCW' 27 | 28 | def __init__(self, start: Vector, end: Vector, 29 | r: float = None, i: float = None, j: float = None, direction=CCW): 30 | self.start = start 31 | self.end = end 32 | self.radius = r 33 | self.direction = direction 34 | 35 | if math.isclose(self.start.x, self.end.x) and math.isclose(self.start.y, self.end.y): 36 | raise ValueError('Arc start and end have the same XY coordinates') 37 | assert self.direction in (Arc.CW, Arc.CCW) 38 | if self.radius is None and (i is None or j is None): 39 | raise ValueError('Specify radius(r) or dX(i) and dY(j)') 40 | if self.radius is None: 41 | self.radius = math.sqrt(i ** 2 + j ** 2) 42 | distance = self.start.distance_to(end) 43 | if math.isclose(self.radius * 2, distance): 44 | self.radius = distance / 2 45 | elif (self.radius * 2) < distance: 46 | raise ValueError('Arc too small to move to new position') 47 | 48 | x0 = self.start.to_array("XYZ") # Start position 49 | x1 = self.end.to_array("XYZ") # Target position 50 | dx = x1 - x0 51 | if self.direction == Arc.CW: 52 | dx = -dx 53 | 54 | mid = (x0 + x1) / 2 55 | centercross = np.cross(dx, np.array([0, 0, 1])) 56 | bisector_direction = centercross / np.sqrt(centercross.dot(centercross)) 57 | bisector_length = math.sqrt((self.radius ** 2) - ((distance / 2) ** 2)) 58 | self.circle_center = mid - bisector_direction * bisector_length 59 | self.i = self.circle_center[0] - x0[0] # dX from start to center 60 | self.j = self.circle_center[1] - x0[1] # dY from start to center 61 | 62 | x0_norm = x0 - self.circle_center 63 | x1_norm = x1 - self.circle_center 64 | arc_angle = math.acos(round(np.dot(x0_norm, x1_norm) / np.linalg.norm(x0_norm) / np.linalg.norm(x1_norm), 10)) 65 | self.arc_length = arc_angle * self.radius 66 | self.spiral_length = math.sqrt(self.arc_length**2 + (self.end.z - self.start.z)**2) 67 | 68 | self.start_angle = np.arctan2(x0_norm[1], x0_norm[0]) 69 | self.end_angle = np.arctan2(x1_norm[1], x1_norm[0]) 70 | if self.direction == Arc.CW: 71 | while self.end_angle > self.start_angle: 72 | self.end_angle -= 2*np.pi 73 | else: 74 | while self.end_angle < self.start_angle: 75 | self.end_angle += 2*np.pi 76 | 77 | 78 | class GCodeGenerator: 79 | def __init__(self, machine_name, layer_height=None): 80 | self.transform = TransformManager(self) 81 | self.tools = ToolManager(self) 82 | 83 | self.buffer = io.StringIO(newline='\n') 84 | self._position = Vector(0.0, 0.0, 0.0) 85 | self.layer_height = layer_height 86 | self.machine_name = machine_name 87 | 88 | self.boundingbox = None 89 | self._active_tool = None 90 | self._initial_tool = None 91 | self._current_layer = -1 92 | self.current_feedrate = 0 93 | self.print_time = 0 94 | 95 | self.plugins = GeneratorPlugin.bind(self) 96 | 97 | self.writeline('M82') 98 | self.writeline() 99 | self.comment('start of gcode') 100 | 101 | def create_tool(self, hotend_name, material: Union[str, TextIO], line_width=None, offset=None): 102 | warnings.warn("GCodeGenerator.create_tool() has been deprecated. Use GCodeGenerator.tools.new() instead.", DeprecationWarning) 103 | self.tools.new(hotend_name=hotend_name, material=material, line_width=line_width, offset=offset) 104 | 105 | def select_tool(self, idx): 106 | warnings.warn("GCodeGenerator.select_tool() has been deprecated. Use GCodeGenerator.tools.select() instead.", DeprecationWarning) 107 | self.tools.select(idx) 108 | 109 | @property 110 | def tool(self) -> Tool: 111 | warnings.warn("GCodeGenerator.tool has been deprecated. Use GCodeGenerator.tools.current instead.", DeprecationWarning) 112 | return self.tools.current 113 | 114 | @property 115 | def initial_tool(self) -> int: 116 | warnings.warn("GCodeGenerator.initial_tool has been deprecated. Use GCodeGenerator.tools.initial instead.", DeprecationWarning) 117 | return self.tools.initial 118 | 119 | @property 120 | def raw_position(self) -> Vector: 121 | return self._position 122 | 123 | @property 124 | def position(self) -> Vector: 125 | return (self.transform.inverse @ self.raw_position).view(Vector) 126 | 127 | def apply_transform(self, vector: np.ndarray): 128 | return (self.transform @ vector).view(Vector) 129 | 130 | def push_transform(self, transform: Transform): 131 | self.transform.append(transform) 132 | def pop_transform(self): 133 | self.transform.pop(-1) 134 | 135 | @staticmethod 136 | def _format_float(number: float) -> str: 137 | return f"{number:.6f}".rstrip("0").rstrip(".") 138 | 139 | def move(self, x: float = None, y: float = None, z: float = None, e: float = None, f: float = None, relative=None, cmd='G0', extra_args=None): 140 | """ 141 | Move the printhead in X,Y,Z and E axes, at a feedrate of F mm/s. 142 | Add `relative=Axis.ALL` to use relative positions (or `relative=Axis.E` to only have relative E axis...) 143 | """ 144 | virtual_position = self.position.copy() 145 | virtual_position.update(x=x, y=y, z=z, e=e, relative=relative) 146 | 147 | old_position = self.raw_position.copy() 148 | new_position = self.apply_transform(virtual_position) 149 | 150 | args = [cmd] 151 | for axis in "XYZE": 152 | if not math.isclose(self.raw_position[axis], new_position[axis]): 153 | args.append(axis + self._format_float(new_position[axis])) 154 | self.raw_position[axis] = new_position[axis] 155 | if f is None: 156 | f = float(self.tools.current.material('travel speed', 150)) 157 | if int(f*60) != int(self.current_feedrate*60): 158 | args.append(f"F{f*60:.0f}") 159 | self.current_feedrate = f 160 | 161 | self.print_time += old_position.distance_to(new_position) / self.current_feedrate 162 | self.tool.position = new_position['e'] 163 | 164 | if extra_args is not None: 165 | args += extra_args 166 | self.writeline(" ".join(args)) 167 | 168 | def set_position(self, x: float = None, y: float = None, z: float = None, e: float = None, relative=None): 169 | """ 170 | Set the current position of XYZ or E axis. 171 | It only keeps track of the position change for the E axis, so the bounding box in the header is probably 172 | going to be wrong when using this method on X,Y or Z axes. 173 | """ 174 | new_position = self.raw_position.copy() 175 | new_position.update(x=x, y=y, z=z, e=e, relative=relative) 176 | 177 | args = ["G92"] 178 | for axis in "XYZE": 179 | if not math.isclose(self.raw_position[axis], new_position[axis]): 180 | args.append(axis + self._format_float(new_position[axis])) 181 | if axis == "E": 182 | self.tool.material.usage += self.tool.position - new_position[axis] 183 | self.tool.position = new_position[axis] 184 | else: 185 | warnings.warn("set_position() not recommended for XYZ axes! Use a transform instead.") 186 | 187 | self.writeline(" ".join(args)) 188 | 189 | def extrude(self, x: float = None, y: float = None, z: float = None, flowrate: float = 1.0, f: float = None, relative=None): 190 | """ 191 | Move the printhead in X,Y,Z axes, and automatically calculate how much filament to extrude 192 | """ 193 | virtual_position = self.position.copy() 194 | virtual_position.update(x=x, y=y, z=z, relative=relative) 195 | 196 | new_position = self.apply_transform(virtual_position) 197 | distance = self.raw_position.distance_to(new_position) 198 | material_volume = self.layer_height * self.tool.line_width * distance # mm^3 of material for the line being extruded 199 | material_distance = material_volume / self.tool.material.area # mm of filament to feed 200 | material_distance *= flowrate 201 | if new_position.z < self.layer_height: # Automatically reduce flowrate if z < layer height 202 | warnings.warn(f'Reducing flowrate because Z position is less than layer height', GCodeWarning, stacklevel=2) 203 | material_distance *= (new_position.z / self.layer_height) 204 | 205 | if f is None: 206 | f = float(self.tools.current.material('print speed', 70)) 207 | 208 | self.update_bbox() # Update bounding box with start of line 209 | self.move(x, y, z, material_distance, f=f, relative=Axis.E, cmd='G1') 210 | self.update_bbox() # Update bounding box with end of line 211 | 212 | def extrude_polar(self, angle, length, flowrate: float = 1.0, f: float = None): 213 | """Extrude in polar coordinates: motion angle [radians] and length [mm]""" 214 | dx = length * np.cos(angle) 215 | dy = length * np.sin(angle) 216 | self.extrude(dx, dy, flowrate=flowrate, f=f, relative=True) 217 | 218 | def update_bbox(self, position=None): 219 | # Keep track of the bounding box of extruded moves 220 | if position is None: 221 | position = self.raw_position 222 | if self.boundingbox is None: 223 | self.boundingbox = (self.raw_position.copy(), self.raw_position.copy()) 224 | for axis in 'xyz': 225 | if position[axis] < self.boundingbox[0][axis]: 226 | self.boundingbox[0][axis] = position[axis] 227 | if position[axis] > self.boundingbox[1][axis]: 228 | self.boundingbox[1][axis] = position[axis] 229 | 230 | def arc(self, 231 | x: float = None, y: float = None, z: float = None, r: float = None, i: float = None, j: float = None, 232 | direction=Arc.CCW, f=None, extrude=True, relative=False, segments: Union[Literal[False], int] = False, 233 | flowrate=1.0): 234 | """ 235 | Move in an arc shape 236 | Specify a radius r, or i and j for X and Y circle center offsets 237 | If `extrude` is True, also extrudes filament during the move 238 | When `segments` is False, it uses G2/G3 to make the arc 239 | Otherwise, it should be an integer of how many linear segments to split the arc into. 240 | """ 241 | virtual_position = self.position.copy() 242 | virtual_position.update(x=x, y=y, z=z, relative=relative) 243 | 244 | start = self.raw_position.copy() 245 | end = self.apply_transform(virtual_position) 246 | 247 | if f is None: 248 | if extrude: 249 | f = self.tool.material('print speed', 70) 250 | else: 251 | f = self.tool.material('travel speed', 150) 252 | 253 | arc = Arc(self.position, virtual_position, r, i, j, direction) 254 | 255 | if segments: 256 | for a, z in np.linspace((arc.start_angle, start.z), (arc.end_angle, end.z), segments+1): 257 | x = arc.circle_center[0] + np.cos(a) * arc.radius 258 | y = arc.circle_center[1] + np.sin(a) * arc.radius 259 | if extrude: 260 | self.extrude(x, y, z, flowrate=flowrate) 261 | else: 262 | self.move(x, y, z) 263 | else: 264 | warnings.warn("GCodeGenerator.arc() called without segment count. Using G2/G3. Not recommended with transforms, and firmware support is not great!") 265 | command = 'G2' if direction == Arc.CW else 'G3' 266 | args = [f'I{arc.i:.3f}'.rstrip('0').rstrip('.'), f'J{arc.j:.3f}'.rstrip('0').rstrip('.')] 267 | 268 | if extrude: 269 | material_volume = self.layer_height * self.tool.line_width * arc.spiral_length 270 | material_distance = material_volume / self.tool.material.area 271 | material_distance *= flowrate 272 | 273 | angles = sorted([arc.start_angle, arc.end_angle]) 274 | for degree in (-360, -270, -180, -90, 0, 90, 180, 270, 360): 275 | a = np.deg2rad(degree) 276 | if angles[0] <= a <= angles[1]: 277 | x = arc.circle_center[0] + arc.radius * np.cos(a) 278 | y = arc.circle_center[1] + arc.radius * np.sin(a) 279 | self.update_bbox(Vector3(x, y, start.z)) 280 | else: 281 | material_distance = None 282 | self.move(x, y, z, material_distance, f, relative=Axis.E, cmd=command, extra_args=args) 283 | 284 | def set_temperature(self, target, tool: int = None, wait=False): 285 | """Set the hotend target temperature, and optionally wait for the temperature to reach this target""" 286 | args = ['M109' if wait else 'M104'] 287 | if tool is not None: 288 | args.append(f'T{tool}') 289 | args.append(f'S{target:.0f}') 290 | self.writeline(' '.join(args)) 291 | 292 | def set_bed_temperature(self, target, wait=False): 293 | """Set the bed target temperature, and optionally wait for the temperature to reach this target""" 294 | args = ['M190' if wait else 'M140', f'S{target:.0f}'] 295 | self.writeline(' '.join(args)) 296 | 297 | def set_fan(self, speed=None): 298 | """Set the object cooling fan speed, 0-100%. If no speed specified, use print profile speed, or 100%""" 299 | if speed is None: 300 | speed = float(self.tool.material('print cooling', 100)) 301 | speed = max(0, min((speed * 255) // 100, 255)) 302 | if speed > 0: 303 | self.writeline(f'M106 S{speed}') 304 | else: 305 | self.writeline(f'M107') 306 | 307 | def retract(self, distance=None, f=None): 308 | """Retract the material `distance` millimeters and `f` mm/s. If not specified, use print profile settings.""" 309 | if distance is None: 310 | distance = float(self.tool.material('retraction amount', 4.5)) 311 | if f is None: 312 | f = float(self.tool.material('retraction speed', 45)) 313 | self.move(e=-distance, f=f, relative=Axis.E) 314 | 315 | def unretract(self, distance=None, f=None): 316 | """Unretract the material `distance` millimeters and `f` mm/s. If not specified, use print profile settings.""" 317 | if distance is None: 318 | distance = float(self.tool.material('retraction amount', 4.5)) 319 | if f is None: 320 | f = float(self.tool.material('retraction speed', 45)) 321 | self.move(e=distance, f=f, relative=Axis.E) 322 | 323 | def prime(self, strategy=PrimeStrategy.BLOB): 324 | """Let the firmware prime the material of the currently active nozzle""" 325 | self.writeline(f'G280 S{strategy}') 326 | 327 | def pause(self, time=None): 328 | """Pause for `time` seconds, or until continued by user if no time specified""" 329 | if time is None: 330 | self.writeline('M0') 331 | else: 332 | self.wait(time) 333 | 334 | def wait(self, time): 335 | """Wait `time` seconds""" 336 | self.writeline(f'G4 S{time}') 337 | 338 | def wait_for_motion(self): 339 | """Wait for motion to complete""" 340 | self.writeline('M400') 341 | 342 | def comment(self, text): 343 | """Add a comment to the GCode""" 344 | self.writeline(f';{text}') 345 | 346 | def mark_layer(self, layer=None): 347 | if layer is None: 348 | layer = self._current_layer + 1 349 | self._current_layer = layer 350 | self.comment(f'LAYER:{self._current_layer}') 351 | 352 | def add_time_estimation(self, time=None): 353 | if time is None: 354 | time = self.print_time 355 | self.comment(f'TIME_ELAPSED:{time:.1f}') 356 | 357 | def writeline(self, line=''): 358 | """Write a line of GCode""" 359 | self.buffer.write(line + '\n') 360 | 361 | def save(self, file: Union[str, typing.IO], **kwargs): 362 | self.comment('end of gcode') 363 | self.writeline() 364 | self.writeline('M107') # Fan off 365 | for i, tool in enumerate(self.tools): 366 | self.writeline(f'M104 T{i} S0') # Hotends off 367 | self.writeline('M140 S0') # Bed off 368 | 369 | if self.boundingbox is None: 370 | self.update_bbox() # Make sure we have at least _something_ to put in the header... 371 | GCodeWriter.write(self, self.buffer.getvalue(), file, **kwargs) 372 | -------------------------------------------------------------------------------- /gcode_generator/gcode_writer/__init__.py: -------------------------------------------------------------------------------- 1 | import io 2 | import typing 3 | from typing import Union 4 | if typing.TYPE_CHECKING: 5 | from .. import GCodeGenerator 6 | 7 | 8 | class GCodeWriter: 9 | _writers = {} 10 | 11 | def __init_subclass__(cls, **kwargs): 12 | if 'extension' in kwargs: 13 | cls._writers[kwargs['extension'].lower()] = cls 14 | 15 | @classmethod 16 | def write(cls, generator: "GCodeGenerator", gcode: str, file: Union[str, typing.IO], 17 | writer: "GCodeWriter" = None, format: str = None, **kwargs): 18 | if writer is None: 19 | if format is None: 20 | if isinstance(file, str): 21 | filename = file 22 | elif hasattr(file, 'name'): 23 | filename = file.name 24 | else: 25 | raise RuntimeError('Could not determine file format!') 26 | name, format = filename.rsplit('.', 1) 27 | if format.lower() not in cls._writers: 28 | raise ValueError(f"Unknown file format '{format}'") 29 | writer = cls._writers[format.lower()] 30 | return writer.write(generator, gcode, file, **kwargs) 31 | 32 | 33 | # Import sub-writers after base definition 34 | from .griffin_writer import GriffinWriter # noqa: E402 35 | from .ufp_writer import UFPWriter # noqa: E402 36 | -------------------------------------------------------------------------------- /gcode_generator/gcode_writer/griffin_writer.py: -------------------------------------------------------------------------------- 1 | import io 2 | import typing 3 | from typing import Union 4 | 5 | from . import GCodeWriter 6 | if typing.TYPE_CHECKING: 7 | from .. import GCodeGenerator 8 | 9 | 10 | class GriffinWriter(GCodeWriter, extension='gcode'): 11 | @classmethod 12 | def write(cls, generator: "GCodeGenerator", gcode: str, file: Union[str, typing.TextIO], **kwargs): 13 | if isinstance(file, str): 14 | # If 'file' is a filename [str], open file and re-run method 15 | with open(file, 'wt', newline='\n') as f: 16 | return cls.write(generator, gcode, f, **kwargs) 17 | else: 18 | file.write(cls.generate_header(generator, **kwargs)) 19 | file.write('\n\n') 20 | file.write(gcode) 21 | 22 | @classmethod 23 | def generate_header(cls, generator, time_estimate=None, **kwargs): 24 | if time_estimate is None: 25 | time_estimate = generator.print_time 26 | bed_temps = [tool.material('heated bed temperature') for tool in generator.tools] 27 | header = { 28 | 'HEADER_VERSION': 0.1, 29 | 'FLAVOR': 'Griffin', 30 | 'GENERATOR': { 31 | 'NAME': 'GCodeGenerator', 32 | 'VERSION': '2.0.0', 33 | 'BUILD_DATE': '2023-04-05' 34 | }, 35 | 'TARGET_MACHINE': {'NAME': generator.machine_name}, 36 | 'PRINT': { 37 | 'TIME': int(time_estimate), 38 | 'SIZE': { 39 | 'MIN': generator.boundingbox[0].to_dict(), 40 | 'MAX': generator.boundingbox[1].to_dict(), 41 | } 42 | }, 43 | 'BUILD_PLATE': { 44 | 'INITIAL_TEMPERATURE': min(temp for temp in bed_temps if temp is not None) 45 | }, 46 | 'EXTRUDER_TRAIN': {} 47 | } 48 | for idx, tool in enumerate(generator.tools): 49 | settings = { 50 | 'MATERIAL': { 51 | 'VOLUME_USED': round((tool.material.usage + tool.position) * tool.material.area, 2), 52 | 'GUID': tool.material.guid 53 | }, 54 | 'NOZZLE': { 55 | 'DIAMETER': tool.nozzle.diameter, 56 | 'NAME': tool.nozzle.name 57 | } 58 | } 59 | if idx == generator.initial_tool: 60 | settings['INITIAL_TEMPERATURE'] = float(tool.material('print temperature', 200)) 61 | else: 62 | settings['INITIAL_TEMPERATURE'] = float(tool.material('standby temperature', 100)) 63 | header['EXTRUDER_TRAIN'][str(idx)] = settings 64 | 65 | build_volume_temps = [tool.material('build volume temperature') for tool in generator.tools] 66 | if any(temp is not None for temp in build_volume_temps): 67 | header['BUILD_VOLUME'] = {'TEMPERATURE': min(temp for temp in build_volume_temps if temp is not None)} 68 | 69 | if kwargs.get('emulate_cura', False): 70 | header['GENERATOR'] = {'NAME': 'Cura_SteamEngine', 'VERSION': '5.3.0', 'BUILD_DATE': '2023-03-07'} 71 | lines = ['START_OF_HEADER', *cls._dict2header(header), 'END_OF_HEADER'] 72 | return '\n'.join(f';{line}' for line in lines) 73 | 74 | @classmethod 75 | def _dict2header(cls, data) -> list[str]: 76 | lines = [] 77 | for key, value in data.items(): 78 | if isinstance(value, dict): 79 | for line in cls._dict2header(value): 80 | lines.append(f'{key.upper()}.{line}') 81 | else: 82 | lines.append(f'{key.upper()}:{value}') 83 | return lines 84 | 85 | 86 | __all__ = ['GriffinWriter'] 87 | -------------------------------------------------------------------------------- /gcode_generator/gcode_writer/ufp_writer.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | import sys 4 | import json 5 | import typing 6 | import pathlib 7 | import zipfile 8 | import xml.etree.ElementTree 9 | 10 | from . import GCodeWriter 11 | from .griffin_writer import GriffinWriter 12 | if typing.TYPE_CHECKING: 13 | from .. import GCodeGenerator 14 | 15 | 16 | class UFPWriter(GCodeWriter, extension='ufp'): 17 | @classmethod 18 | def write(cls, generator: "GCodeGenerator", gcode: str, file: str, image=None, name=None, **kwargs): 19 | with zipfile.ZipFile(file, 'w') as ufp: 20 | # Add gcode file 21 | with ufp.open('/3D/model.gcode', 'w') as gcode_file: 22 | GriffinWriter.write(generator, gcode, io.TextIOWrapper(gcode_file, newline='\n'), **kwargs) 23 | 24 | # Add thumbnail image 25 | if image is None: 26 | image = os.path.join(os.path.dirname(__file__), '../thumbnail.png') 27 | cls.add_binary(ufp, image, '/Metadata/thumbnail.png') 28 | 29 | # Add UFP_Global metadata 30 | with ufp.open('/Metadata/UFP_Global.json', 'w') as ufp_global: 31 | if name is None: 32 | name = os.path.basename(ufp.filename).rsplit('.', 1)[0] 33 | global_data = {'metadata': {'objects': [{'name': name}]}} 34 | json.dump(global_data, io.TextIOWrapper(ufp_global, newline='\n')) 35 | 36 | # Add fdm_material files 37 | material_files = [] 38 | for tool in generator.tools: 39 | fn = f'/Materials/{os.path.basename(tool.material.file.filename)}' 40 | if fn not in material_files: 41 | cls.add_binary(ufp, bytes(tool.material.file), fn) 42 | material_files.append(fn) 43 | 44 | # Add xml files 45 | with ufp.open('/3D/_rels/model.gcode.rels', 'w') as file: 46 | UFPRelationships('/Metadata/thumbnail.png', *material_files).write(file) 47 | with ufp.open('/_rels/.rels', 'w') as file: 48 | UFPRelationships('/3D/model.gcode', '/Metadata/UFP_Global.json').write(file) 49 | with ufp.open('/[Content_Types].xml', 'w') as file: 50 | UFPContentTypes().write(file) 51 | 52 | @classmethod 53 | def add_binary(cls, ufp, file, target): 54 | if isinstance(file, str): 55 | with open(file, 'rb') as f: 56 | return cls.add_binary(ufp, f, target) 57 | else: 58 | if not isinstance(file, bytes): 59 | file = file.read() 60 | with ufp.open(target, 'w') as wfile: 61 | wfile.write(file) 62 | 63 | 64 | class UFPXML(xml.etree.ElementTree.Element): 65 | def write(self, file, encoding='utf-8', xml_declaration=True): 66 | if isinstance(file, str): 67 | with open(file, 'wb') as f: 68 | return self.write(f) 69 | else: 70 | tree = xml.etree.ElementTree.ElementTree(self) 71 | if sys.version_info.major > 3 or (sys.version_info.major == 3 and sys.version_info.minor >= 9): 72 | xml.etree.ElementTree.indent(tree, ' '*2) 73 | tree.write(file, encoding=encoding, xml_declaration=xml_declaration) 74 | 75 | 76 | class UFPRelationships(UFPXML): 77 | TYPES = { 78 | 'thumbnail.png': 'http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail', 79 | '*.fdm_material': 'http://schemas.ultimaker.org/package/2018/relationships/material', 80 | '*.gcode': 'http://schemas.ultimaker.org/package/2018/relationships/gcode', 81 | 'UFP_Global.json': 'http://schemas.ultimaker.org/package/2018/relationships/opc_metadata' 82 | } 83 | 84 | def __init__(self, *relationships: str): 85 | super().__init__('Relationships', {'xmlns': 'http://schemas.openxmlformats.org/package/2006/relationships'}) 86 | for i, relationship in enumerate(relationships): 87 | path = pathlib.PurePath(relationship) 88 | rel_type = [value for key, value in self.TYPES.items() if path.match(key)][0] 89 | xml.etree.ElementTree.SubElement(self, 'Relationship', {'Target': relationship, 90 | 'Type': rel_type, 91 | 'Id': f'rel{i}'}) 92 | 93 | 94 | class UFPContentTypes(UFPXML): 95 | UFP_CONTENT_TYPES = { 96 | 'rels': 'application/vnd.openxmlformats-package.relationships+xml', 97 | 'gcode': 'text/x-gcode', 98 | 'json': 'application/json', 99 | 'png': 'image/png', 100 | 'xml.fdm_material': 'application/x-ultimaker-material-profile' 101 | } 102 | 103 | def __init__(self, types=None): 104 | super().__init__('Types', {'xmlns': 'http://schemas.openxmlformats.org/package/2006/content-types'}) 105 | if types is None: 106 | types = self.UFP_CONTENT_TYPES 107 | for ext, mime in types.items(): 108 | xml.etree.ElementTree.SubElement(self, 'Default', {'Extension': ext, 'ContentType': mime}) 109 | 110 | 111 | __all__ = ['UFPWriter'] 112 | -------------------------------------------------------------------------------- /gcode_generator/machine.py: -------------------------------------------------------------------------------- 1 | import re 2 | import math 3 | import enum 4 | from typing import Union, TextIO, Any, TYPE_CHECKING, Optional 5 | 6 | import numpy as np 7 | 8 | from .vector import Vector, Transform 9 | from .fdm_material import FDMReader 10 | if TYPE_CHECKING: 11 | from .gcode_generator import GCodeGenerator 12 | 13 | 14 | class PrimeStrategy(enum.IntEnum): 15 | BLOB = 0 16 | NONE = 1 17 | 18 | 19 | class NozzleOffset(Transform): 20 | def __new__(cls, dx=0, dy=0, dz=0): 21 | matrix = np.identity(5) 22 | matrix[0:3, -1] = (dx, dy, dz) 23 | return super().__new__(cls, matrix) 24 | 25 | 26 | class Tool: 27 | """ 28 | Tool class - Initialize with a hotend name, material name, machine name and line width to print with. 29 | """ 30 | def __init__(self, hotend_name: str, material: Union[str, TextIO], machine_name: str, 31 | line_width: float = None, offset: Vector = None): 32 | self.nozzle = Hotend(hotend_name, machine_name) 33 | self.material = Material(material, machine_name, self.nozzle.name) 34 | self.line_width = line_width if line_width is not None else self.nozzle.diameter 35 | self.position = 0.0 36 | if offset is None: 37 | offset = Vector(0,0,0) 38 | self.transform = NozzleOffset(offset['x'], offset['y'], offset['z']) 39 | 40 | 41 | class Hotend: 42 | """ 43 | Hotend class - Initialize with a hotend name and machine name 44 | hotend_name should be a print-core name ('AA 0.4' 'BB 0.8' 'CC 0.6' 'AA 0.25', ...) 45 | or a UM2+ nozzle size ('0.4 mm', '0.8mm', ...) 46 | """ 47 | def __init__(self, hotend_name: str, machine: str): 48 | self.name = hotend_name 49 | match = re.match(r'(?:[A-Z]{2}\+? )?(\d+\.\d+)(?: ?mm)?', self.name) 50 | if match: 51 | self.diameter = float(match.group(1)) 52 | 53 | 54 | class Material: 55 | """ 56 | Material class - Initialize with a fdm_material file, and optionally machine name and hotend name 57 | When a machine name and hotend name are specified, it will add the specific settings applicable 58 | """ 59 | def __init__(self, material: Union[str, TextIO], machine=None, hotend=None): 60 | self.file = FDMReader(material) 61 | self.material = self.file.getroot() 62 | 63 | self.diameter = float(self.material.find('properties/diameter').text) 64 | self.guid = self.material.find('metadata/GUID').text.strip() 65 | self.usage = 0.0 66 | 67 | self.settings: dict[str, Any] = {} 68 | for element in self.material.findall('settings/setting'): 69 | self.settings[element.attrib['key']] = element.text 70 | self._update_settings_from_xml(self.material.find('./settings')) 71 | 72 | # Add machine-specific settings 73 | # find XML 'machine' tag that has a `machine_identifier` child with product=[machine] attribute 74 | machine_settings = self.material.find(f"./settings/machine/machine_identifier[@product='{machine}']/..") 75 | if machine_settings is not None: 76 | self._update_settings_from_xml(machine_settings) 77 | hotend_settings = machine_settings.find(f"hotend[@id='{hotend}']") 78 | if hotend_settings: 79 | self._update_settings_from_xml(hotend_settings) 80 | 81 | def _update_settings_from_xml(self, element): 82 | for child in element.findall('./setting'): 83 | self.settings[child.attrib['key']] = child.text.strip() 84 | 85 | @property 86 | def area(self): 87 | """Cross-sectional area of material""" 88 | return math.pi * ((self.diameter / 2)**2) 89 | 90 | def __call__(self, key, default=None): 91 | return self.settings.get(key, default) 92 | 93 | def __getitem__(self, item): 94 | return self.settings[item] 95 | 96 | def __setitem__(self, key, value): 97 | self.settings[key] = value 98 | 99 | 100 | class ToolManager(list): 101 | def __init__(self, generator: "GCodeGenerator"): 102 | super().__init__() 103 | self._generator = generator 104 | self._active_tool: Optional[int] = None 105 | self._initial_tool: Optional[int] = None 106 | 107 | def new(self, hotend_name, material: Union[str, TextIO], line_width=None, offset=None): 108 | """ 109 | Add a tool to the machine. 110 | For hotend_name, see the Hotend class 111 | For material_name, see the Material class 112 | line_width is the extrusion width - set to the nozzle diameter if not specified 113 | offset is the XY(Z) offset of this nozzle compared to the printer position 114 | """ 115 | tool = Tool(hotend_name, material, self._generator.machine_name, line_width, offset=offset) 116 | self.append(tool) 117 | 118 | def select(self, index: int): 119 | """Select a tool to print with, and set the extrusion position to 0""" 120 | if self._active_tool is None: 121 | self._initial_tool = index 122 | else: 123 | self._generator.set_temperature(int(self.current.material('standby temperature', 100)), tool=self._active_tool, wait=False) 124 | 125 | self._active_tool = index 126 | self._generator.writeline(f'T{index}') 127 | self._generator.set_position(e=0) 128 | # Set and wait new tool to print temperature 129 | self._generator.set_temperature(int(self.current.material('print temperature', 200)), tool=index, wait=True) 130 | 131 | for transform_index, transform in enumerate(self._generator.transform): 132 | if isinstance(transform, NozzleOffset): 133 | self._generator.transform[transform_index] = self.current.transform 134 | break 135 | else: 136 | self._generator.transform.insert(0, self.current.transform) 137 | 138 | @property 139 | def current(self) -> Tool: 140 | if len(self) == 0: 141 | raise RuntimeError('No tool created') 142 | if self._active_tool is None: 143 | self.select(0) 144 | return self[self._active_tool] 145 | 146 | @property 147 | def initial(self) -> int: 148 | if self._initial_tool is None: 149 | raise RuntimeError("No tool used yet!") 150 | return self._initial_tool 151 | -------------------------------------------------------------------------------- /gcode_generator/plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | When creating a function that makes a more complex pattern than a single command, it should be written as a plugin to keep the code organized. 4 | A plugin is simply a function with a Plugin decorator. When called, the GCodeGenerator instance is automatically passed as the first parameter to the function. 5 | 6 | An example plugin function is as follows: 7 | ```python 8 | import typing 9 | 10 | from gcode_generator.plugins import GeneratorPlugin 11 | if typing.TYPE_CHECKING: 12 | from gcode_generator import GCodeGenerator 13 | 14 | @GeneratorPlugin 15 | def my_little_function(generator: "GCodeGenerator", param1, param2): 16 | generator.move(param1) 17 | generator.set_fan(param2) 18 | ... 19 | ``` 20 | 21 | This plugin can then be used in other code: 22 | ```python 23 | from gcode_generator import GCodeGenerator 24 | 25 | generator = GCodeGenerator('Ultimaker S5', layer_height=0.15) 26 | generator.create_tool('AA 0.4', 'generic_pla') 27 | generator.plugins.my_little_function(1, 2) 28 | ``` 29 | -------------------------------------------------------------------------------- /gcode_generator/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Type, Callable 3 | 4 | 5 | class GeneratorPluginMeta(type): 6 | def __call__(cls: "GeneratorPlugin", func: Callable) -> Callable: 7 | path = cls._classpath 8 | if path not in cls._plugins: 9 | cls._plugins[path] = {} 10 | cls._plugins[path][func.__name__] = func 11 | return func 12 | 13 | def bind(cls, *args, **kwargs): 14 | return type.__call__(cls, *args, **kwargs) 15 | 16 | 17 | class GeneratorPlugin(metaclass=GeneratorPluginMeta): 18 | _plugins = {} 19 | _sub_managers = {} 20 | _classpath = 'plugins' 21 | 22 | @classmethod 23 | def bind(cls, generator): 24 | # Actual constructor 25 | return GeneratorPluginMeta.bind(cls, generator) 26 | 27 | def __init__(self, generator): 28 | self.generator = generator 29 | if self._classpath in GeneratorPlugin._sub_managers: 30 | for name, Manager in GeneratorPlugin._sub_managers[self._classpath].items(): 31 | setattr(self, name, Manager.bind(self.generator)) 32 | 33 | def __init_subclass__(cls, **kwargs): 34 | if 'name' in kwargs: 35 | name = kwargs['name'] 36 | for base in cls.__bases__: 37 | if issubclass(base, GeneratorPlugin): 38 | cls._classpath = f'{base._classpath}.{name}' 39 | if base._classpath not in GeneratorPlugin._sub_managers: 40 | GeneratorPlugin._sub_managers[base._classpath] = {} 41 | GeneratorPlugin._sub_managers[base._classpath][name] = cls 42 | setattr(base, name, cls) 43 | 44 | def __getattr__(self, item: str): 45 | if self._classpath in GeneratorPlugin._plugins and item in GeneratorPlugin._plugins[self._classpath]: 46 | return functools.partial(GeneratorPlugin._plugins[self._classpath][item], self.generator) 47 | raise AttributeError 48 | 49 | @classmethod 50 | def category(cls, name) -> Type["GeneratorPlugin"]: 51 | if hasattr(cls, name): 52 | return getattr(cls, name) 53 | return type(name+'Plugins', (cls,), {}, name=name) # noqa https://youtrack.jetbrains.com/issue/PY-46044 54 | -------------------------------------------------------------------------------- /gcode_generator/plugins/patterns.py: -------------------------------------------------------------------------------- 1 | import math 2 | import typing 3 | 4 | import numpy as np 5 | 6 | from . import GeneratorPlugin 7 | if typing.TYPE_CHECKING: 8 | from .. import GCodeGenerator 9 | 10 | 11 | def remap(x, in_range, out_range): 12 | in_lo, in_hi = in_range 13 | out_lo, out_hi = out_range 14 | return (x - in_lo) / (in_hi - in_lo) * (out_hi - out_lo) + out_lo 15 | 16 | 17 | @GeneratorPlugin 18 | def circle(gcode: "GCodeGenerator", 19 | centerx, centery, radius, segments=100, 20 | startangle=0, endangle=2*math.pi, direction='CCW', 21 | f=None, extrude=True): 22 | 23 | gcode.move(centerx + math.cos(startangle) * radius, 24 | centery + math.sin(startangle) * radius) 25 | 26 | assert direction in ('CW', 'CCW') 27 | if direction == 'CW': 28 | # If counter-clockwise, move in negative angle direction, endangle should be smaller than startangle 29 | while endangle > startangle: 30 | endangle -= 2*math.pi 31 | 32 | for angle in np.linspace(startangle, endangle, segments): 33 | # angle = (i / segments) * 2 * math.pi + startangle 34 | x = centerx + math.cos(angle) * radius 35 | y = centery + math.sin(angle) * radius 36 | if extrude: 37 | gcode.extrude(x, y, f=f) 38 | else: 39 | gcode.move(x, y, f=f) 40 | 41 | 42 | @GeneratorPlugin 43 | def rectangle(gcode: "GCodeGenerator", centerx, centery, width, height, radius=0, flowrate=1.0, f=None): 44 | x0, y0 = centerx - width/2, centery - height/2 45 | x1, y1 = centerx + width/2, centery + height/2 46 | if radius > 0: 47 | if radius > (min(width, height) / 2): 48 | raise ValueError('Corner radius does not fit in rectangle') 49 | gcode.move(x0 + radius, y0) 50 | gcode.extrude(x1 - radius, y0, f=f, flowrate=flowrate) 51 | gcode.arc(x1, y0 + radius, radius, direction='CCW', f=f, flowrate=flowrate) 52 | gcode.extrude(x1, y1-radius, f=f, flowrate=flowrate) 53 | gcode.arc(x1 - radius, y1, radius, direction='CCW', f=f, flowrate=flowrate) 54 | gcode.extrude(x0 + radius, y1, f=f, flowrate=flowrate) 55 | gcode.arc(x0, y1 - radius, radius, direction='CCW', f=f, flowrate=flowrate) 56 | gcode.extrude(x0, y0 + radius, flowrate=flowrate) 57 | gcode.arc(x0 + radius, y0, radius, direction='CCW', f=f, flowrate=flowrate) 58 | else: 59 | gcode.move(x0, y0) 60 | gcode.extrude(x1, y0, f=f, flowrate=flowrate) 61 | gcode.extrude(x1, y1, f=f, flowrate=flowrate) 62 | gcode.extrude(x0, y1, f=f, flowrate=flowrate) 63 | gcode.extrude(x0, y0, f=f, flowrate=flowrate) 64 | 65 | 66 | @GeneratorPlugin 67 | def filled_rectangle(gcode: "GCodeGenerator", centerx, centery, width, height, flowrate=1.0, f=None): 68 | while True: 69 | if (width >= 2*gcode.tool.line_width) and (height >= 2*gcode.tool.line_width): 70 | rectangle(gcode, centerx, centery, width, height, flowrate=flowrate, f=f) 71 | width -= gcode.tool.line_width*2 72 | height -= gcode.tool.line_width*2 73 | else: 74 | break 75 | -------------------------------------------------------------------------------- /gcode_generator/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ultimaker/GCodeGenerator/11f3655006500f1bc7d09fdbe05fa63723af255b/gcode_generator/thumbnail.png -------------------------------------------------------------------------------- /gcode_generator/vector.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from typing import Iterator, Type, TYPE_CHECKING, Union 3 | import enum 4 | import numpy as np 5 | 6 | if TYPE_CHECKING: 7 | from .gcode_generator import GCodeGenerator 8 | 9 | 10 | class Axis(enum.Flag): 11 | X = 1 << 0 12 | Y = 1 << 1 13 | Z = 1 << 2 14 | E = 1 << 3 15 | 16 | NONE = 0 17 | ALL = X | Y | Z | E 18 | 19 | def upper(self): 20 | return self.name.upper() 21 | 22 | 23 | class Vector(np.ndarray): 24 | 25 | AXES = "XYZE" 26 | 27 | def __new__(cls, *args, **kwargs): 28 | array = np.zeros(len(cls.AXES) + 1) 29 | array[0:len(args)] = args 30 | array[-1] = 1 31 | return array.view(cls) 32 | 33 | def __getitem__(self, item): 34 | if isinstance(item, (str, Axis)) and item.upper() in self.AXES: 35 | item = self.AXES.index(item.upper()) 36 | return super().__getitem__(item) 37 | 38 | def __setitem__(self, item, value): 39 | if isinstance(item, (str, Axis)) and item.upper() in self.AXES: 40 | item = self.AXES.index(item.upper()) 41 | return super().__setitem__(item, value) 42 | 43 | def __getattr__(self, item): 44 | if item.upper() in self.AXES: 45 | return self[item] 46 | return super().__getattribute__(item) 47 | 48 | def __setattr__(self, item, value): 49 | if item.upper() in self.AXES: 50 | self[item] = value 51 | 52 | def to_array(self, axes = Axis.ALL): 53 | return np.array([self[axis] for axis in axes]) 54 | 55 | def to_dict(self): 56 | return {axis: self[axis] for axis in self.AXES} 57 | 58 | def items(self) -> Iterator[tuple[str, float]]: 59 | for axis in self.AXES: 60 | yield axis, self[axis] 61 | 62 | def distance_to(self, other: "Vector") -> float: 63 | return np.linalg.norm(self - other) 64 | 65 | def update(self, *, relative: Union[bool, Axis] = Axis.NONE, **kwargs): 66 | if isinstance(relative, bool): 67 | relative = Axis.ALL if relative else Axis.NONE 68 | for key, value in kwargs.items(): 69 | if value is not None and key.upper() in self.AXES: 70 | if relative is not None and Axis[key.upper()] in relative: 71 | self[key] += value 72 | else: 73 | self[key] = value 74 | 75 | 76 | class Transform(np.ndarray): 77 | def __new__(cls, matrix: np.ndarray, generator: "GCodeGenerator" = None): 78 | if not isinstance(matrix, np.ndarray): 79 | matrix = np.array(matrix) 80 | if len(matrix.shape) != 2 or matrix.shape[0] != matrix.shape[1]: 81 | raise ValueError("Input has to be a 2D square matrix") 82 | self = matrix.view(cls) 83 | self.generator = generator 84 | return self 85 | 86 | def __enter__(self): 87 | self.generator.push_transform(self) 88 | 89 | def __exit__(self, exc_type, exc_val, exc_tb): 90 | if self.generator is None: 91 | raise RuntimeError("Transform cannot be used as a context without a GCodeGenerator specified") 92 | self.generator.pop_transform() 93 | 94 | 95 | def update_on_change(cls: Type): 96 | """Class decorator to call self._update() whenever the inherited list/dict/set is mutated.""" 97 | names = { 98 | "__delitem__", "__iadd__", "__iand__", "__imul__", "__ior__", "__isub__", "__ixor__", "__setitem__", 99 | "add", "append", "clear", "difference_update", "discard", "extend", "insert", "intersection_update", "pop", 100 | "popitem", "remove", "reverse", "setdefault", "sort", "symmetric_difference_update", "update" 101 | } 102 | 103 | def create_wrapper(key): 104 | @functools.wraps(getattr(cls, key)) 105 | def wrapper(self, *args, **kwargs): 106 | result = getattr(super(self.__class__, self), key)(*args, **kwargs) 107 | self._update() 108 | return result 109 | return wrapper 110 | 111 | return type(cls.__name__, (cls,), {name: create_wrapper(name) for name in names if hasattr(cls, name)}) 112 | 113 | 114 | @update_on_change 115 | class TransformManager(list): 116 | def __init__(self, generator: "GCodeGenerator"): 117 | super().__init__() 118 | self._generator = generator 119 | self._total_transform: np.ndarray = np.identity(5) 120 | self._inverse_transform: np.ndarray = np.identity(5) 121 | 122 | def __call__(self, matrix: np.ndarray): 123 | return Transform(matrix, generator=self._generator) 124 | 125 | def __matmul__(self, other): 126 | return self._total_transform @ other 127 | 128 | @property 129 | def matrix(self): 130 | return self._total_transform 131 | 132 | @property 133 | def inverse(self): 134 | return self._inverse_transform 135 | 136 | def _update(self): 137 | print("updating total transform") 138 | total = np.identity(5) 139 | for matrix in self[::-1]: 140 | total = total @ matrix 141 | self._total_transform = total 142 | self._total_transform.setflags(write=False) 143 | self._inverse_transform = np.linalg.inv(total) 144 | self._inverse_transform.setflags(write=False) 145 | 146 | @classmethod 147 | def _translation_matrix(cls, dx=0, dy=0, dz=0): 148 | transform = np.identity(5) 149 | transform[0:3, -1] = [dx, dy, dz] 150 | return transform 151 | 152 | def translate(self, dx=0, dy=0, dz=0): 153 | return self(self._translation_matrix(dx=dx, dy=dy, dz=dz)) 154 | 155 | @classmethod 156 | def _rotation_matrix(cls, angle, x=0, y=0): 157 | transform = np.identity(5) 158 | transform[0, 0] = np.cos(angle) 159 | transform[0, 1] = -np.sin(angle) 160 | transform[1, 0] = np.sin(angle) 161 | transform[1, 1] = np.cos(angle) 162 | return cls._translation_matrix(x, y) @ transform @ cls._translation_matrix(-x, -y) 163 | 164 | def rotate(self, angle, x=0, y=0): 165 | return self(self._rotation_matrix(angle=angle, x=x, y=y)) 166 | 167 | 168 | __all__ = ["Axis", "Vector", "Transform", "TransformManager"] 169 | 170 | if __name__ == '__main__': 171 | a = np.array([1,2,3]) 172 | v = a.view(Vector) 173 | print(v) 174 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "gcode_generator" 7 | version = "2.1" 8 | description = "A python library for generating 3D printer GCode" 9 | readme = "README.md" 10 | requires-python = ">=3.7" 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "Operating System :: OS Independent", 14 | ] 15 | 16 | [project.urls] 17 | "Homepage" = "https://github.com/Ultimaker/GCodeGenerator" 18 | "Bug Tracker" = "https://github.com/Ultimaker/GCodeGenerator/issues" 19 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | -------------------------------------------------------------------------------- /tests/test_plugins.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import gcode_generator 4 | from gcode_generator.plugins import GeneratorPlugin 5 | 6 | 7 | @GeneratorPlugin 8 | def test1(gcode): 9 | assert isinstance(gcode, gcode_generator.GCodeGenerator) 10 | return 1 11 | 12 | subplugins = GeneratorPlugin.category('subplugins') 13 | @subplugins 14 | def test2(gcode): 15 | assert isinstance(gcode, gcode_generator.GCodeGenerator) 16 | return 2 17 | 18 | @subplugins.category('subsub') 19 | def test3(gcode): 20 | assert isinstance(gcode, gcode_generator.GCodeGenerator) 21 | return 3 22 | 23 | @subplugins.category('subsub') 24 | def test4(gcode): 25 | assert isinstance(gcode, gcode_generator.GCodeGenerator) 26 | return 4 27 | 28 | 29 | class TestPlugin(unittest.TestCase): 30 | def setUp(self): 31 | self.gcode = gcode_generator.GCodeGenerator('bla', 1) 32 | 33 | def test_plugin(self): 34 | self.assertEqual(self.gcode.plugins.test1(), 1) 35 | 36 | def test_subplugin(self): 37 | self.assertEqual(self.gcode.plugins.subplugins.test2(), 2) 38 | 39 | def test_nested_plugins(self): 40 | self.assertEqual(self.gcode.plugins.subplugins.subsub.test3(), 3) 41 | 42 | def test_not_found(self): 43 | with self.assertRaises(AttributeError): 44 | self.gcode.plugins.not_there() 45 | 46 | def test_same_category_name(self): 47 | self.assertIs(GeneratorPlugin.category('test'), GeneratorPlugin.category('test')) 48 | 49 | 50 | if __name__ == '__main__': 51 | unittest.main() 52 | --------------------------------------------------------------------------------