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