From fd29e955e56130c684abb27b7ad1b7b5a8d0b555 Mon Sep 17 00:00:00 2001 From: hyyz17200 Date: Tue, 28 Apr 2026 14:39:54 +0800 Subject: [PATCH] Initial commit --- .gitignore | 18 + LICENSE | 674 +++++++++++++++++++++++++ README.md | 399 +++++++++++++++ a1_swap_mod_packer/__init__.py | 2 + a1_swap_mod_packer/archive.py | 16 + a1_swap_mod_packer/builder.py | 177 +++++++ a1_swap_mod_packer/cli.py | 111 ++++ a1_swap_mod_packer/core.py | 110 ++++ a1_swap_mod_packer/gcode.py | 106 ++++ a1_swap_mod_packer/gui.py | 833 +++++++++++++++++++++++++++++++ a1_swap_mod_packer/metadata.py | 171 +++++++ a1_swap_mod_packer/models.py | 86 ++++ a1_swap_mod_packer/patches.py | 87 ++++ a1_swap_mod_packer/paths.py | 26 + a1_swap_mod_packer/planning.py | 146 ++++++ gcode_patches.ini | 11 + requirements.txt | 2 + run_cli.py | 4 + run_gui.py | 4 + swap_gcode/LX_0.07.22.gcode | 100 ++++ swap_gcode/LX_1.02.26.gcode | 115 +++++ swap_gcode/LX_1.02.26_slow.gcode | 115 +++++ 22 files changed, 3313 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 a1_swap_mod_packer/__init__.py create mode 100644 a1_swap_mod_packer/archive.py create mode 100644 a1_swap_mod_packer/builder.py create mode 100644 a1_swap_mod_packer/cli.py create mode 100644 a1_swap_mod_packer/core.py create mode 100644 a1_swap_mod_packer/gcode.py create mode 100644 a1_swap_mod_packer/gui.py create mode 100644 a1_swap_mod_packer/metadata.py create mode 100644 a1_swap_mod_packer/models.py create mode 100644 a1_swap_mod_packer/patches.py create mode 100644 a1_swap_mod_packer/paths.py create mode 100644 a1_swap_mod_packer/planning.py create mode 100644 gcode_patches.ini create mode 100644 requirements.txt create mode 100644 run_cli.py create mode 100644 run_gui.py create mode 100644 swap_gcode/LX_0.07.22.gcode create mode 100644 swap_gcode/LX_1.02.26.gcode create mode 100644 swap_gcode/LX_1.02.26_slow.gcode diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..172cf32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.pyc +*.pyo +*.pyd +.venv/ +venv/ +env/ +dist/ +build/ +*.log +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +*.bak +/settings.json +codec.py \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f2e7bf3 --- /dev/null +++ b/README.md @@ -0,0 +1,399 @@ +# A1 Swap Mod Packer + +A1 Swap Mod Packer is an open-source implementation 3MF packer for Bambu Lab A1 SwapMod workflows. + +It takes one or more A1-sliced `.3mf` files, repeats their plate G-code according to copy counts, inserts an external SwapMod ejection/swap G-code block, and writes a new packed `.3mf` that can be sent to the printer. + +## Notes + +- This project contains no proprietary code. All functionalities were implemented independently by comparing the states before and after 3MF file generation. +- Currently, this project has only been tested and verified on Windows 10. +- Always test a generated file on a safe/simple print before using it in production. + +## Key features + +- GUI for drag-and-drop batch packing. +- CLI for repeatable automation. +- Multiple input `.3mf` files. +- Per-file copy count. +- Automatic time and filament summary from `Metadata/slice_info.config`. +- External SwapMod G-code template folder. +- Editable G-code patch file. +- Optional remaining-time plate-number encoding through `M73 R...`. +- Combined mode: all input rows become one packed 3MF. +- Individual batch mode: every input row becomes its own packed 3MF. +- GUI settings are saved to the program folder as `settings.json`. + +## Installation for source use + +Python 3.10 or newer is recommended. + +Install dependency: + +```bash +pip install -r requirements.txt +``` +Run the GUI: + +```bash +python -m a1_swap_mod_packer.gui +``` + +or + +```bash +python run_gui.py +``` + +Run the CLI: + +```bash +python -m a1_swap_mod_packer.cli --help +``` + +or + +```bash +python run_cli.py +``` + +## Swap G-code templates + +The GUI automatically scans this fixed folder: + +```text +swap_gcode/ +``` + +Plain UTF-8 G-code files are read directly. + +In the GUI: + +1. Put template files in `swap_gcode/`. +2. Click **Refresh** next to **Swap G-code**. +3. Select the template from the dropdown. + +## Editable G-code patches + +The GUI and CLI read the fixed patch file: + +```text +gcode_patches.ini +``` + +Default rule: + +```ini +[patch.a1_start_y] +enabled = true +flag = G0 X128 F30000 +find = G0 Y254 F3000 +replace = G0 Y250 F3000 ; Patched +max_count = 1 +``` + +Meaning: + +- Search after the optional `flag` line. +- Replace the first matching `find` line with `replace`. +- Apply at most `max_count` replacements. + +You can disable this in the GUI with **G-code patches**, or edit the INI file and keep the option enabled. + +The swap insertion marker can also be edited: + +```ini +[swap] +insert_before_marker = ;=====printer finish sound========= +``` + +## GUI guide + +### Input 3MF files + +The input table supports: + +- **Add 3MF**: choose one or more `.3mf` files. +- Drag and drop `.3mf` files into the table. +- Drag and drop a folder into the table. The GUI adds top-level `.3mf` files from that folder. +- **Remove**: remove selected rows. +- **Remove All**: clear the full input list. +- **Move Up / Move Down**: change input order. +- **Apply Default Copies to Selected**: overwrite selected rows with the current default copy count. + +Columns: + +- **3MF path**: source file path. +- **Copies**: how many times this source should be repeated. +- **Time**: estimated print time multiplied by copies. +- **Filament**: estimated filament multiplied by copies. + +The time and filament values are read from: + +```text +Metadata/slice_info.config +``` + +If the source 3MF lacks this metadata, the GUI shows `Unknown`. + +The summary line under the input list shows the total plate count, estimated time, and filament for the current table. + +### Build 3MF button + +The **Build 3MF** button is placed below the input list on the right side for fast batch operation. + +In normal combined mode, one click creates one output 3MF containing all input rows. + +In individual batch mode, one click creates one output 3MF per input row. + +### Swap G-code + +Selects the ejection/swap G-code block inserted into each repeated plate. + +The dropdown is generated from files in: + +```text +swap_gcode/ +``` + +Buttons: + +- **Refresh**: rescan the folder. +- **Open Folder**: open the template folder. + +### Default copies for new inputs + +Sets the default copy count applied to newly added or dragged-in files. + +This does not automatically change existing rows. Use **Apply Default Copies to Selected** for existing rows. + +### Bed cooldown + +Controls whether the tool inserts a bed temperature wait before the SwapMod G-code block. + +Enabled example: + +```gcode +M190 S45 +``` + +If disabled, no `M190` line is added by the packer. + +### Wait after ejection + +Adds a dwell after the SwapMod G-code block. + +Example for 45 seconds: + +```gcode +G4 P45000 +``` + +### Remaining-time plate number + +When enabled, the packer offsets `M73 ... R...` remaining-time values by: + +```text +plate_number × 100 hours × 60 minutes +``` + +This allows the A1 remaining-time hour hundreds digit to display the current plate number. + +Example concept: + +- Plate 1: +6000 minutes +- Plate 2: +12000 minutes +- Plate 3: +18000 minutes + +### Final swap + +When enabled, the swap/ejection block is also inserted after the final repeated plate. + +When disabled, the final repeated plate ends normally without running the swap G-code block. + +### G-code patches + +Applies rules from: + +```text +gcode_patches.ini +``` + +The **Open Config** button opens that fixed file for editing. + +### 3MF metadata + +Options: + +- **Keep source prediction and weight** + Leaves `slice_info.config` prediction and weight values from the base 3MF unchanged. This best matches the observed vendor packer behavior. + +- **Sum prediction and filament** + Updates the first plate metadata using the sum of all repeated source plates. This is more logically correct for statistics, but may differ from the vendor software. + +### Batch mode + +#### Combined mode + +Default behavior. + +All input rows are packed into one output file. + +Example: + +```text +A.3mf copies 2 +B.3mf copies 3 +``` + +Output: + +```text +one packed 3MF containing A, A, B, B, B +``` + +#### Individual batch mode + +When enabled, the GUI shows an explanation popup. + +Every input row is treated as an independent build. + +Example: + +```text +A.3mf copies 5 +B.3mf copies 5 +C.3mf copies 5 +``` + +Output: + +```text +5 Plates - A.3mf +5 Plates - B.3mf +5 Plates - C.3mf +``` + +This is intended for quickly converting a large batch of independent single-plate 3MF files into multi-copy SwapMod packs. + +It does **not** combine all inputs into one file. + +### Input handling + +Options: + +- **Skip duplicate file paths when adding inputs** + Prevents accidentally adding the same path multiple times. + +- **Clear input list after successful build** + Clears the table after a successful build. In individual batch mode, the list is cleared only if all outputs are built successfully. + +### Output directory + +If this field is empty: + +- Combined mode writes next to the first input file. +- Individual batch mode writes each output next to its own input file. + +If a directory is selected, all outputs go to that directory. + +### Output filename rule + +Default: + +```text +{plates} Plates - {sources}.3mf +``` + +Click the `?` button next to the rule field to show token help. + +Available tokens: + +| Token | Meaning | +|---|---| +| `{source}` | First input file stem, without `.3mf` | +| `{sources}` | Source summary. One source uses its stem; multiple sources become `first_source_and_N_more` | +| `{plates}` | Total plate count in this output | +| `{copies}` | Total copy count in this output | +| `{date}` | Current date as `YYYYMMDD` | +| `{time}` | Current time as `HHMMSS` | + +In individual batch mode, tokens are calculated per input row. + +Examples: + +```text +{plates} Plates - {sources}.3mf +SwapMod - {source} - x{copies}.3mf +{date}_{time}_{source}.3mf +``` + +## GUI settings + +The GUI writes settings to the program folder: + +```text +settings.json +``` + +This is intentional so a portable extracted folder or future packaged `.exe` build keeps its options next to the application. + +Saved options include: + +- Selected Swap G-code file. +- Default copies. +- Bed cooldown settings. +- Wait after ejection. +- Remaining-time plate-number switch. +- Final swap switch. +- G-code patch switch. +- Metadata mode. +- Individual batch mode. +- Input handling options. +- Output directory. +- Output filename rule. + +## CLI examples + +Build one source with five copies: + +```bash +python -m a1_swap_mod_packer.cli build \ + --item "SC05720_01.gcode(1).3mf" 5 \ + --swap-gcode "a1_swap.gcode" \ + --cool-bed 45 \ + --wait 45 \ + --show-plate-number \ + -o "5 Plates - SC05720_01.gcode(1).3mf" +``` + +Build multiple sources into one output: + +```bash +python -m a1_swap_mod_packer.cli build \ + --item "A.3mf" 2 \ + --item "B.3mf" 3 \ + --swap-gcode "a1_swap.gcode" \ + -o "5 Plates - A_and_B.3mf" +``` + +Use summed metadata instead of source metadata: + +```bash +python -m a1_swap_mod_packer.cli build \ + --item "A.3mf" 5 \ + --swap-gcode "a1_swap.gcode" \ + --metadata-mode sum \ + -o "5 Plates - A.3mf" +``` + +Disable editable G-code patches: + +```bash +python -m a1_swap_mod_packer.cli build \ + --item "A.3mf" 5 \ + --swap-gcode "a1_swap.gcode" \ + --no-gcode-patches \ + -o "5 Plates - A.3mf" +``` + diff --git a/a1_swap_mod_packer/__init__.py b/a1_swap_mod_packer/__init__.py new file mode 100644 index 0000000..6c84513 --- /dev/null +++ b/a1_swap_mod_packer/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.4.0" +APP_NAME = "A1 Swap Mod Packer" diff --git a/a1_swap_mod_packer/archive.py b/a1_swap_mod_packer/archive.py new file mode 100644 index 0000000..db79e34 --- /dev/null +++ b/a1_swap_mod_packer/archive.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import re +import zipfile + +GCODE_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$") +MD5_MEMBER_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode\.md5$") + + +def list_gcode_members(archive: zipfile.ZipFile) -> list[str]: + members: list[tuple[int, str]] = [] + for name in archive.namelist(): + match = GCODE_MEMBER_RE.match(name) + if match: + members.append((int(match.group(1)), name)) + return [name for _, name in sorted(members)] diff --git a/a1_swap_mod_packer/builder.py b/a1_swap_mod_packer/builder.py new file mode 100644 index 0000000..5736d86 --- /dev/null +++ b/a1_swap_mod_packer/builder.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import hashlib +import os +import shutil +import tempfile +import zipfile +from pathlib import Path + +from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members +from .gcode import ( + apply_line_ending, + apply_plate_number_offset, + build_swap_block, + insert_swap_block, + normalize_newlines, + read_swap_gcode_file, + resolve_swap_gcode_path, +) +from .metadata import read_filament_metadata, read_slice_plate_metadata, safe_float, update_first_slice_info +from .models import BuildOptions, BuildResult, GcodePatchConfig, PlateJob, PlateSource +from .patches import apply_gcode_patches, parse_patch_config + +try: + from PIL import Image, ImageDraw, ImageFont +except Exception: # pragma: no cover + Image = None + ImageDraw = None + ImageFont = None + + +def load_plate_sources(job: PlateJob) -> list[PlateSource]: + if job.copies < 1: + raise ValueError(f"Invalid copy count for {job.source_3mf}: {job.copies}") + with zipfile.ZipFile(job.source_3mf, "r") as archive: + members = list_gcode_members(archive) + if not members: + raise ValueError(f"No Metadata/plate_N.gcode member was found in {job.source_3mf}") + plate_metadata = read_slice_plate_metadata(archive) + filament_metadata = read_filament_metadata(archive) + sources: list[PlateSource] = [] + for member in members: + match = GCODE_MEMBER_RE.match(member) + plate_index = int(match.group(1)) if match else 1 + raw = archive.read(member) + text = raw.decode("utf-8-sig", errors="replace") + metadata = plate_metadata.get(plate_index, {}) + filament = filament_metadata.get(plate_index, {}) + weight = safe_float(metadata.get("weight")) + used_g = safe_float(filament.get("used_g")) + sources.append( + PlateSource( + source_3mf=job.source_3mf, + member_name=member, + gcode_text=text, + prediction_seconds=safe_float(metadata.get("prediction")), + weight_grams=weight if weight is not None else used_g, + filament_used_m=safe_float(filament.get("used_m")), + filament_used_g=used_g if used_g is not None else weight, + ) + ) + return sources + + +def expand_jobs(jobs: list[PlateJob]) -> list[PlateSource]: + expanded: list[PlateSource] = [] + for job in jobs: + sources = load_plate_sources(job) + for _ in range(job.copies): + expanded.extend(sources) + if not expanded: + raise ValueError("No input plates were provided.") + return expanded + + +def process_plate_gcode( + source: PlateSource, + plate_number: int, + total_plates: int, + options: BuildOptions, + swap_gcode_text: str, + patch_config: GcodePatchConfig, +) -> str: + text = normalize_newlines(source.gcode_text) + if options.apply_gcode_patches: + text = apply_gcode_patches(text, patch_config) + if options.show_plate_number: + text = apply_plate_number_offset(text, plate_number) + should_swap = options.swap_after_final or plate_number < total_plates + if should_swap: + swap_block = build_swap_block(swap_gcode_text, options.cool_bed_temp, options.wait_after_eject_seconds) + text = insert_swap_block(text, swap_block, patch_config.insert_before_marker) + return text.rstrip("\n") + "\n" + + +def update_preview_label(png_bytes: bytes, label: str, small: bool = False) -> bytes: + if Image is None or ImageDraw is None: + return png_bytes + import io + + try: + image = Image.open(io.BytesIO(png_bytes)).convert("RGBA") + except Exception: + return png_bytes + draw = ImageDraw.Draw(image) + size = max(16, image.width // (12 if small else 8)) + font = None + for font_path in ( + Path(os.environ.get("WINDIR", "")) / "Fonts" / "arialbd.ttf", + Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"), + Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf"), + ): + if font_path.exists() and ImageFont is not None: + try: + font = ImageFont.truetype(str(font_path), size) + break + except Exception: + font = None + if font is None and ImageFont is not None: + font = ImageFont.load_default() + margin = max(8, image.width // 24) + text_bbox = draw.textbbox((0, 0), label, font=font) + x = margin + y = image.height - margin - (text_bbox[3] - text_bbox[1]) + draw.text((x, y), label, font=font, fill=(0, 255, 0, 255)) + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def write_output_3mf(base_3mf: Path, output_3mf: Path, gcode_bytes: bytes, sources: list[PlateSource], options: BuildOptions) -> str: + md5 = hashlib.md5(gcode_bytes).hexdigest() + with zipfile.ZipFile(base_3mf, "r") as src, zipfile.ZipFile(output_3mf, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=1) as dst: + for item in src.infolist(): + name = item.filename + if GCODE_MEMBER_RE.match(name) or MD5_MEMBER_RE.match(name): + continue + data = src.read(name) + if name == "Metadata/slice_info.config": + data = update_first_slice_info(data, sources, options) + elif options.add_preview_label and name in {"Metadata/plate_1.png", "Metadata/plate_1_small.png"}: + data = update_preview_label(data, f"{len(sources)} plates", small=name.endswith("_small.png")) + dst.writestr(item, data) + dst.writestr("Metadata/plate_1.gcode", gcode_bytes) + dst.writestr("Metadata/plate_1.gcode.md5", md5) + return md5 + + +def build_packed_3mf(jobs: list[PlateJob], options: BuildOptions) -> BuildResult: + sources = expand_jobs(jobs) + swap_gcode_path = resolve_swap_gcode_path(options.swap_gcode, options.swap_gcode_dir) + swap_gcode_text = read_swap_gcode_file(swap_gcode_path) + patch_config = parse_patch_config() if options.apply_gcode_patches else GcodePatchConfig(rules=()) + processed: list[str] = [] + for plate_number, source in enumerate(sources, start=1): + processed.append(process_plate_gcode(source, plate_number, len(sources), options, swap_gcode_text, patch_config)) + final_gcode = "\n".join(item.rstrip("\n") for item in processed) + "\n" + gcode_bytes = apply_line_ending(final_gcode, options.line_ending) + output_3mf = options.output_3mf + output_3mf.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf", dir=str(output_3mf.parent)) as temp_file: + temp_path = Path(temp_file.name) + try: + md5 = write_output_3mf(sources[0].source_3mf, temp_path, gcode_bytes, sources, options) + shutil.move(str(temp_path), str(output_3mf)) + finally: + if temp_path.exists(): + temp_path.unlink(missing_ok=True) + total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None) + total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None) + return BuildResult( + output_3mf=output_3mf, + plate_count=len(sources), + total_prediction_seconds=total_prediction if total_prediction > 0 else None, + total_weight_grams=total_weight if total_weight > 0 else None, + gcode_md5=md5, + ) diff --git a/a1_swap_mod_packer/cli.py b/a1_swap_mod_packer/cli.py new file mode 100644 index 0000000..495289f --- /dev/null +++ b/a1_swap_mod_packer/cli.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from . import APP_NAME +from .core import ( + BuildOptions, + PlateJob, + build_packed_3mf, + list_swap_gcode_files, +) +from .paths import default_patch_config_path, default_swap_gcode_dir + + +def parse_item(values: list[str]) -> PlateJob: + if len(values) != 2: + raise argparse.ArgumentTypeError("Each --item needs a path and a copy count.") + path = Path(values[0]) + try: + copies = int(values[1]) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"Invalid copy count: {values[1]}") from exc + return PlateJob(path, copies) + + +def build_command(args: argparse.Namespace) -> int: + jobs: list[PlateJob] = [] + for item in args.item or []: + jobs.append(parse_item(item)) + for input_path in args.inputs or []: + jobs.append(PlateJob(Path(input_path), args.copies)) + if not jobs: + raise SystemExit("No input 3MF file was provided.") + cool_bed_temp = None if args.no_bed_cooldown else args.cool_bed + options = BuildOptions( + swap_gcode=args.swap_gcode, + output_3mf=Path(args.output), + cool_bed_temp=cool_bed_temp, + wait_after_eject_seconds=args.wait, + show_plate_number=args.show_plate_number, + swap_after_final=not args.no_swap_after_final, + metadata_mode=args.metadata_mode, + line_ending=args.line_ending, + add_preview_label=not args.no_preview_label, + apply_gcode_patches=not args.no_gcode_patches, + swap_gcode_dir=Path(args.swap_gcode_dir) if args.swap_gcode_dir else None, + ) + result = build_packed_3mf(jobs, options) + print(f"Output: {result.output_3mf}") + print(f"Plates: {result.plate_count}") + print(f"G-code MD5: {result.gcode_md5}") + if result.total_prediction_seconds is not None: + print(f"Source print time: {int(result.total_prediction_seconds)} seconds") + if result.total_weight_grams is not None: + print(f"Source filament weight: {result.total_weight_grams:.2f} g") + return 0 + + +def list_swap_gcode_command(args: argparse.Namespace) -> int: + directory = Path(args.swap_gcode_dir) if args.swap_gcode_dir else default_swap_gcode_dir() + files = list_swap_gcode_files(directory) + if not files: + print(f"No swap G-code files found in: {directory}") + return 0 + for path in files: + print(path.name) + return 0 + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="a1-swap-mod-packer", description="Pack repeated Bambu A1 SwapMod plates into one 3MF job.") + subparsers = parser.add_subparsers(dest="command", required=True) + + build = subparsers.add_parser("build", help="Build a packed 3MF file.") + build.add_argument("inputs", nargs="*", help="Input 3MF files. Use --copies for the same copy count on all of them.") + build.add_argument("--item", nargs=2, action="append", metavar=("PATH", "COPIES"), help="Add an input 3MF with its own copy count. Can be used multiple times.") + build.add_argument("-o", "--output", required=True, help="Output 3MF path.") + build.add_argument("--swap-gcode", required=True, help="Swap G-code file name in swap_gcode, or an explicit file path.") + build.add_argument("--swap-gcode-dir", default=None, help=f"Template directory. Default: {default_swap_gcode_dir()}") + build.add_argument("--copies", type=int, default=1, help="Copy count for positional input files.") + build.add_argument("--cool-bed", type=int, default=45, help="Bed temperature to wait for before running the swap G-code.") + build.add_argument("--no-bed-cooldown", action="store_true", help="Do not insert M190 before the swap code.") + build.add_argument("--wait", type=int, default=45, help="Seconds to wait after plate ejection.") + build.add_argument("--show-plate-number", action="store_true", help="Add 100 hours per plate number to M73 R values.") + build.add_argument("--no-swap-after-final", action="store_true", help="Do not run the swap G-code after the last plate.") + build.add_argument("--metadata-mode", choices=("source", "sum"), default="source", help="How to write slice_info prediction and weight.") + build.add_argument("--line-ending", choices=("lf", "crlf"), default="crlf", help="Line ending for the generated G-code.") + build.add_argument("--no-preview-label", action="store_true", help="Do not add a plate-count label to the first preview image.") + build.add_argument("--no-gcode-patches", action="store_true", help=f"Do not apply editable patches from {default_patch_config_path()}.") + build.set_defaults(func=build_command) + + list_cmd = subparsers.add_parser("list-swap-gcode", help="List files from the swap_gcode directory.") + list_cmd.add_argument("--swap-gcode-dir", default=None, help=f"Template directory. Default: {default_swap_gcode_dir()}") + list_cmd.set_defaults(func=list_swap_gcode_command) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = create_parser() + args = parser.parse_args(argv) + try: + return int(args.func(args)) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/a1_swap_mod_packer/core.py b/a1_swap_mod_packer/core.py new file mode 100644 index 0000000..d9cd21a --- /dev/null +++ b/a1_swap_mod_packer/core.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from .archive import GCODE_MEMBER_RE, MD5_MEMBER_RE, list_gcode_members +from .builder import ( + build_packed_3mf, + expand_jobs, + load_plate_sources, + process_plate_gcode, + update_preview_label, + write_output_3mf, +) +from .gcode import ( + M73_RE, + apply_line_ending, + apply_plate_number_offset, + build_swap_block, + format_duration, + format_filament, + insert_swap_block, + list_swap_gcode_files, + normalize_newlines, + read_swap_gcode_file, + resolve_swap_gcode_path, +) +from .metadata import ( + multiply_summary, + read_3mf_summary, + read_filament_metadata, + read_slice_plate_metadata, + safe_float, + summarize_jobs, + update_first_slice_info, +) +from .models import ( + DEFAULT_INSERT_BEFORE_MARKER, + BuildOptions, + BuildResult, + BuildSummary, + GcodePatchConfig, + GcodePatchRule, + LineEnding, + MetadataMode, + PlateJob, + PlateSource, + ThreeMfSummary, +) +from .patches import apply_gcode_patches, apply_patch_rule, parse_bool, parse_patch_config +from .planning import ( + DEFAULT_OUTPUT_PATTERN, + OutputNamingOptions, + OutputSummary, + make_unique_for_run, + resolve_output_path, + sanitize_filename, + summarize_jobs_for_output, + three_mf_summary_from_mapping, +) + +__all__ = [ + "DEFAULT_INSERT_BEFORE_MARKER", + "DEFAULT_OUTPUT_PATTERN", + "GCODE_MEMBER_RE", + "M73_RE", + "MD5_MEMBER_RE", + "BuildOptions", + "BuildResult", + "BuildSummary", + "GcodePatchConfig", + "GcodePatchRule", + "LineEnding", + "MetadataMode", + "OutputNamingOptions", + "OutputSummary", + "PlateJob", + "PlateSource", + "ThreeMfSummary", + "apply_gcode_patches", + "apply_line_ending", + "apply_patch_rule", + "apply_plate_number_offset", + "build_packed_3mf", + "build_swap_block", + "expand_jobs", + "format_duration", + "format_filament", + "insert_swap_block", + "list_gcode_members", + "list_swap_gcode_files", + "load_plate_sources", + "make_unique_for_run", + "multiply_summary", + "normalize_newlines", + "parse_bool", + "parse_patch_config", + "process_plate_gcode", + "read_3mf_summary", + "read_filament_metadata", + "read_slice_plate_metadata", + "read_swap_gcode_file", + "resolve_output_path", + "resolve_swap_gcode_path", + "safe_float", + "sanitize_filename", + "summarize_jobs", + "summarize_jobs_for_output", + "three_mf_summary_from_mapping", + "update_first_slice_info", + "update_preview_label", + "write_output_3mf", +] diff --git a/a1_swap_mod_packer/gcode.py b/a1_swap_mod_packer/gcode.py new file mode 100644 index 0000000..6ec9209 --- /dev/null +++ b/a1_swap_mod_packer/gcode.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from .models import DEFAULT_INSERT_BEFORE_MARKER, LineEnding +from .paths import default_swap_gcode_dir + +M73_RE = re.compile(r"^(M73\s+P\d+\s+R)(\d+)(.*)$", re.MULTILINE) + + +def normalize_newlines(text: str) -> str: + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def apply_line_ending(text: str, line_ending: LineEnding) -> bytes: + normalized = normalize_newlines(text) + if line_ending == "crlf": + normalized = normalized.replace("\n", "\r\n") + return normalized.encode("utf-8") + + +def read_swap_gcode_file(path: Path) -> str: + return path.read_text(encoding="utf-8-sig", errors="replace") + + +def resolve_swap_gcode_path(value: Path | str, swap_gcode_dir: Path | None = None) -> Path: + raw = Path(value) + if raw.exists(): + return raw + search_dir = swap_gcode_dir or default_swap_gcode_dir() + candidate = search_dir / str(value) + if candidate.exists(): + return candidate + if raw.suffix: + raise FileNotFoundError(f"Swap G-code file not found: {value}") + for suffix in (".gcode", ".nc", ".ngc", ".txt"): + candidate = search_dir / f"{value}{suffix}" + if candidate.exists(): + return candidate + raise FileNotFoundError(f"Swap G-code file not found: {value}") + + +def list_swap_gcode_files(directory: Path | None = None) -> list[Path]: + root = directory or default_swap_gcode_dir() + if not root.exists(): + return [] + allowed = {".gcode", ".nc", ".ngc", ".txt"} + return sorted(path for path in root.iterdir() if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in allowed) + + +def format_duration(seconds: float | None) -> str: + if seconds is None: + return "Unknown" + total = max(0, int(round(seconds))) + hours, rem = divmod(total, 3600) + minutes, secs = divmod(rem, 60) + if hours: + return f"{hours}h {minutes:02d}m" + if minutes: + return f"{minutes}m {secs:02d}s" + return f"{secs}s" + + +def format_filament(weight_grams: float | None, used_m: float | None = None) -> str: + if weight_grams is None and used_m is None: + return "Unknown" + parts: list[str] = [] + if weight_grams is not None: + parts.append(f"{weight_grams:.2f} g") + if used_m is not None: + parts.append(f"{used_m:.2f} m") + return " / ".join(parts) + + +def apply_plate_number_offset(gcode: str, plate_number: int) -> str: + minute_offset = plate_number * 100 * 60 + + def replace(match: re.Match[str]) -> str: + return f"{match.group(1)}{int(match.group(2)) + minute_offset}{match.group(3)}" + + return M73_RE.sub(replace, gcode) + + +def build_swap_block(swap_gcode: str, cool_bed_temp: int | None, wait_seconds: int) -> str: + parts: list[str] = [] + if cool_bed_temp is not None: + parts.append(f"M190 S{int(cool_bed_temp)}") + parts.append(normalize_newlines(swap_gcode).rstrip("\n")) + if wait_seconds > 0: + parts.append(f"G4 P{int(wait_seconds) * 1000}") + return "\n".join(part for part in parts if part) + "\n" + + +def insert_swap_block(gcode: str, swap_block: str, insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER) -> str: + text = normalize_newlines(gcode) + marker = insert_before_marker.strip() + if marker: + marker_index = text.find("\n" + marker) + if marker_index == -1 and text.startswith(marker): + marker_index = 0 + if marker_index != -1: + prefix = text[: marker_index + (1 if marker_index > 0 else 0)] + suffix = text[marker_index + (1 if marker_index > 0 else 0) :] + return prefix + swap_block + suffix + return text.rstrip("\n") + "\n" + swap_block diff --git a/a1_swap_mod_packer/gui.py b/a1_swap_mod_packer/gui.py new file mode 100644 index 0000000..7459268 --- /dev/null +++ b/a1_swap_mod_packer/gui.py @@ -0,0 +1,833 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Any, Callable + +if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ: + os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype" + +try: + from PySide6.QtCore import QEvent, Qt, QUrl + from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont + from PySide6.QtWidgets import ( + QApplication, + QAbstractItemView, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, + QWidget, + ) +except ImportError as exc: # pragma: no cover + raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc + +from . import APP_NAME +from .core import ( + BuildOptions, + PlateJob, + ThreeMfSummary, + build_packed_3mf, + format_duration, + format_filament, + list_swap_gcode_files, + read_3mf_summary, +) +from .paths import default_patch_config_path, default_swap_gcode_dir, user_settings_path +from .planning import ( + DEFAULT_OUTPUT_PATTERN, + OutputNamingOptions, + OutputSummary, + make_unique_for_run, + resolve_output_path, + summarize_jobs_for_output, + three_mf_summary_from_mapping, +) + +SUMMARY_ROLE = Qt.UserRole + 100 + + +class DropTableWidget(QTableWidget): + def __init__(self, on_files_dropped: Callable[[list[Path]], None], parent: QWidget | None = None) -> None: + super().__init__(parent) + self.on_files_dropped = on_files_dropped + self.setAcceptDrops(True) + self.viewport().setAcceptDrops(True) + self.viewport().installEventFilter(self) + self.setDragDropMode(QAbstractItemView.DropOnly) + + def eventFilter(self, watched: object, event: QEvent) -> bool: + if watched is self.viewport(): + if event.type() in {QEvent.DragEnter, QEvent.DragMove}: + drag_event = event # type: ignore[assignment] + if self._has_3mf_urls(drag_event): + drag_event.acceptProposedAction() + return True + if event.type() == QEvent.Drop: + drop_event = event # type: ignore[assignment] + paths = self._paths_from_urls(drop_event.mimeData().urls()) + if paths: + self.on_files_dropped(paths) + drop_event.acceptProposedAction() + return True + return super().eventFilter(watched, event) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + if self._has_3mf_urls(event): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dragMoveEvent(self, event: QDragMoveEvent) -> None: + if self._has_3mf_urls(event): + event.acceptProposedAction() + return + super().dragMoveEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: + paths = self._paths_from_urls(event.mimeData().urls()) + if paths: + self.on_files_dropped(paths) + event.acceptProposedAction() + return + super().dropEvent(event) + + def _has_3mf_urls(self, event: Any) -> bool: + if not event.mimeData().hasUrls(): + return False + return bool(self._paths_from_urls(event.mimeData().urls())) + + def _paths_from_urls(self, urls: list[QUrl]) -> list[Path]: + result: list[Path] = [] + for url in urls: + local_path = url.toLocalFile() + if not local_path: + continue + path = Path(local_path) + if path.is_dir(): + result.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf")) + elif path.is_file() and path.suffix.lower() == ".3mf": + result.append(path) + return result + + +class MainWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.setWindowTitle(APP_NAME) + self.resize(1180, 860) + self.setAcceptDrops(True) + self._updating_table = False + self._loading_settings = True + self._settings = self.load_settings() + self.build_ui() + self.load_swap_gcode_to_combo() + self.restore_settings_to_ui() + self.connect_option_signals() + self._loading_settings = False + self.update_total_summary() + self.update_output_preview() + + def load_settings(self) -> dict[str, Any]: + path = user_settings_path() + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + return {} + + def save_settings(self) -> None: + path = user_settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._settings, indent=2), encoding="utf-8") + + def build_ui(self) -> None: + central = QWidget(self) + root = QVBoxLayout(central) + + file_group = QGroupBox("Input 3MF files") + file_layout = QVBoxLayout(file_group) + self.table = DropTableWidget(self.add_paths) + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels(["3MF path", "Copies", "Time", "Filament"]) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setAlternatingRowColors(True) + self.table.setToolTip("Drag .3mf files or folders here. Folders add all top-level .3mf files.") + self.table.itemChanged.connect(self.on_table_item_changed) + file_layout.addWidget(self.table) + + self.total_summary_label = QLabel("Total: 0 plates | Time: Unknown | Filament: Unknown") + file_layout.addWidget(self.total_summary_label) + + file_buttons = QHBoxLayout() + add_button = QPushButton("Add 3MF") + remove_button = QPushButton("Remove") + remove_all_button = QPushButton("Remove All") + up_button = QPushButton("Move Up") + down_button = QPushButton("Move Down") + apply_default_copies_button = QPushButton("Apply Default Copies to Selected") + self.build_button = QPushButton("Build 3MF") + self.build_button.setMinimumHeight(64) + self.build_button.setMinimumWidth(180) + build_font = self.build_button.font() + build_font.setPointSize(build_font.pointSize() + 2) + build_font.setBold(True) + self.build_button.setFont(build_font) + + add_button.clicked.connect(self.add_files) + remove_button.clicked.connect(self.remove_selected) + remove_all_button.clicked.connect(self.remove_all) + up_button.clicked.connect(lambda: self.move_selected(-1)) + down_button.clicked.connect(lambda: self.move_selected(1)) + apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected) + self.build_button.clicked.connect(self.build_output) + + for button in (add_button, remove_button, remove_all_button, up_button, down_button, apply_default_copies_button): + file_buttons.addWidget(button) + file_buttons.addStretch(1) + file_buttons.addWidget(self.build_button) + file_layout.addLayout(file_buttons) + root.addWidget(file_group) + + options_group = QGroupBox("Packing options") + options_layout = QFormLayout(options_group) + + self.swap_gcode_combo = QComboBox() + self.swap_gcode_combo.setMinimumWidth(520) + swap_gcode_row = QHBoxLayout() + refresh_button = QPushButton("Refresh") + open_folder_button = QPushButton("Open Folder") + refresh_button.clicked.connect(self.load_swap_gcode_to_combo) + open_folder_button.clicked.connect(self.open_swap_gcode_folder) + swap_gcode_row.addWidget(self.swap_gcode_combo, 1) + swap_gcode_row.addWidget(refresh_button) + swap_gcode_row.addWidget(open_folder_button) + options_layout.addRow("Swap G-code", swap_gcode_row) + + self.default_copies_spin = QSpinBox() + self.default_copies_spin.setRange(1, 9999) + self.default_copies_spin.setValue(1) + options_layout.addRow("Default copies for new inputs", self.default_copies_spin) + + self.bed_cooldown_check = QCheckBox("Wait for bed cooldown") + self.bed_cooldown_check.setChecked(True) + self.cool_bed_spin = QSpinBox() + self.cool_bed_spin.setRange(0, 120) + self.cool_bed_spin.setValue(45) + bed_row = QHBoxLayout() + bed_row.addWidget(self.bed_cooldown_check) + bed_row.addWidget(self.cool_bed_spin) + bed_row.addWidget(QLabel("°C")) + bed_row.addStretch(1) + options_layout.addRow("Bed cooldown", bed_row) + + self.wait_spin = QSpinBox() + self.wait_spin.setRange(0, 3600) + self.wait_spin.setValue(45) + self.wait_spin.setSuffix(" s") + options_layout.addRow("Wait after ejection", self.wait_spin) + + self.show_plate_number_check = QCheckBox("Show current plate in the hundreds digit of remaining time") + self.show_plate_number_check.setChecked(True) + options_layout.addRow("Remaining-time plate number", self.show_plate_number_check) + + self.swap_final_check = QCheckBox("Run swap G-code after the last plate") + self.swap_final_check.setChecked(True) + options_layout.addRow("Final swap", self.swap_final_check) + + self.patch_check = QCheckBox("Apply editable G-code patches from gcode_patches.ini") + self.patch_check.setChecked(True) + patch_row = QHBoxLayout() + patch_path_label = QLabel(str(default_patch_config_path())) + patch_path_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + open_patch_button = QPushButton("Open Config") + open_patch_button.clicked.connect(self.open_patch_config) + patch_row.addWidget(self.patch_check) + patch_row.addWidget(patch_path_label, 1) + patch_row.addWidget(open_patch_button) + options_layout.addRow("G-code patches", patch_row) + + self.metadata_combo = QComboBox() + self.metadata_combo.addItem("Keep source prediction and weight", "source") + self.metadata_combo.addItem("Sum prediction and filament", "sum") + options_layout.addRow("3MF metadata", self.metadata_combo) + + self.individual_batch_check = QCheckBox("Individual batch mode") + self.individual_batch_check.setToolTip( + "Build each input row as a separate output file, using that row's copy count." + ) + options_layout.addRow("Batch mode", self.individual_batch_check) + + self.clear_after_build_check = QCheckBox("Clear input list after successful build") + self.clear_after_build_check.setChecked(False) + self.skip_duplicates_check = QCheckBox("Skip duplicate file paths when adding inputs") + self.skip_duplicates_check.setChecked(True) + input_handling_row = QHBoxLayout() + input_handling_row.addWidget(self.skip_duplicates_check) + input_handling_row.addWidget(self.clear_after_build_check) + input_handling_row.addStretch(1) + options_layout.addRow("Input handling", input_handling_row) + root.addWidget(options_group) + + output_group = QGroupBox("Output") + output_layout = QFormLayout(output_group) + output_dir_row = QHBoxLayout() + self.output_dir_edit = QLineEdit() + self.output_dir_edit.setPlaceholderText("Leave empty to write next to the input file") + browse_output_dir_button = QPushButton("Browse") + browse_output_dir_button.clicked.connect(self.choose_output_dir) + output_dir_row.addWidget(self.output_dir_edit, 1) + output_dir_row.addWidget(browse_output_dir_button) + output_layout.addRow("Output directory", output_dir_row) + + self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN) + self.output_name_edit.setPlaceholderText("Use tokens such as {source}, {sources}, {plates}, {copies}, {date}, {time}") + filename_rule_row = QHBoxLayout() + output_rule_help_button = QPushButton("?") + output_rule_help_button.setFixedWidth(34) + output_rule_help_button.setToolTip("Show filename token help") + output_rule_help_button.clicked.connect(self.show_output_rule_help) + filename_rule_row.addWidget(self.output_name_edit, 1) + filename_rule_row.addWidget(output_rule_help_button) + output_layout.addRow("Output filename rule", filename_rule_row) + + self.output_preview_label = QLabel("Output path preview: -") + output_layout.addRow("Preview", self.output_preview_label) + root.addWidget(output_group) + + self.log = QTextEdit() + self.log.setReadOnly(True) + self.log.setMinimumHeight(130) + root.addWidget(self.log) + + self.setCentralWidget(central) + + def connect_option_signals(self) -> None: + self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings) + self.default_copies_spin.valueChanged.connect(self.save_current_settings) + self.bed_cooldown_check.stateChanged.connect(self.save_current_settings) + self.cool_bed_spin.valueChanged.connect(self.save_current_settings) + self.wait_spin.valueChanged.connect(self.save_current_settings) + self.show_plate_number_check.stateChanged.connect(self.save_current_settings) + self.swap_final_check.stateChanged.connect(self.save_current_settings) + self.patch_check.stateChanged.connect(self.save_current_settings) + self.metadata_combo.currentIndexChanged.connect(self.save_current_settings) + self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled) + self.clear_after_build_check.stateChanged.connect(self.save_current_settings) + self.skip_duplicates_check.stateChanged.connect(self.save_current_settings) + self.output_dir_edit.textChanged.connect(self.on_output_rule_changed) + self.output_name_edit.textChanged.connect(self.on_output_rule_changed) + + def restore_settings_to_ui(self) -> None: + options = self._settings.get("packing_options", {}) + if not isinstance(options, dict): + return + self.default_copies_spin.setValue(int(options.get("default_copies", 1))) + self.bed_cooldown_check.setChecked(bool(options.get("bed_cooldown_enabled", True))) + self.cool_bed_spin.setValue(int(options.get("cool_bed_temp", 45))) + self.wait_spin.setValue(int(options.get("wait_after_eject_seconds", 45))) + self.show_plate_number_check.setChecked(bool(options.get("show_plate_number", True))) + self.swap_final_check.setChecked(bool(options.get("swap_after_final", True))) + self.patch_check.setChecked(bool(options.get("apply_gcode_patches", True))) + self.individual_batch_check.setChecked(bool(options.get("individual_batch_mode", False))) + self.clear_after_build_check.setChecked(bool(options.get("clear_after_build", False))) + self.skip_duplicates_check.setChecked(bool(options.get("skip_duplicates", True))) + self.output_dir_edit.setText(str(options.get("output_directory", ""))) + self.output_name_edit.setText(str(options.get("output_filename_rule", DEFAULT_OUTPUT_PATTERN))) + metadata_mode = options.get("metadata_mode", "source") + metadata_index = self.metadata_combo.findData(metadata_mode) + if metadata_index >= 0: + self.metadata_combo.setCurrentIndex(metadata_index) + swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode") + if swap_gcode: + index = self.swap_gcode_combo.findData(str(swap_gcode)) + if index < 0: + index = self.swap_gcode_combo.findText(Path(str(swap_gcode)).name) + if index >= 0: + self.swap_gcode_combo.setCurrentIndex(index) + + def collect_current_settings(self) -> dict[str, Any]: + return { + "swap_gcode": self.swap_gcode_combo.currentData() or "", + "default_copies": self.default_copies_spin.value(), + "bed_cooldown_enabled": self.bed_cooldown_check.isChecked(), + "cool_bed_temp": self.cool_bed_spin.value(), + "wait_after_eject_seconds": self.wait_spin.value(), + "show_plate_number": self.show_plate_number_check.isChecked(), + "swap_after_final": self.swap_final_check.isChecked(), + "apply_gcode_patches": self.patch_check.isChecked(), + "metadata_mode": self.metadata_combo.currentData(), + "individual_batch_mode": self.individual_batch_check.isChecked(), + "clear_after_build": self.clear_after_build_check.isChecked(), + "skip_duplicates": self.skip_duplicates_check.isChecked(), + "output_directory": self.output_dir_edit.text().strip(), + "output_filename_rule": self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN, + } + + def save_current_settings(self) -> None: + if self._loading_settings: + return + self._settings["packing_options"] = self.collect_current_settings() + self._settings["last_swap_gcode"] = self.swap_gcode_combo.currentData() or "" + self.save_settings() + + def on_output_rule_changed(self) -> None: + self.update_output_preview() + self.save_current_settings() + + def on_individual_batch_toggled(self, state: int) -> None: + self.update_output_preview() + self.save_current_settings() + if self._loading_settings: + return + if state != 0: + QMessageBox.information( + self, + "Individual batch mode", + "Individual batch mode treats every input row as a separate build.\n\n" + "Example: if you add 20 single-plate 3MF files and set copies to 5, " + "Build 3MF will create 20 separate packed files. Each output contains " + "only that source file repeated 5 times.\n\n" + "This is useful for quickly batch-converting many independent 3MF jobs " + "into multi-copy SwapMod packs. It does not combine all input files into one 3MF.", + ) + + def show_output_rule_help(self) -> None: + QMessageBox.information( + self, + "Output filename rule", + "Available filename tokens:\n\n" + "{source} - First input file stem, without .3mf\n" + "{sources} - Source name summary. For multiple different inputs, it becomes " + "first_source_and_N_more\n" + "{plates} - Total plate count in the output\n" + "{copies} - Total copy count used for the output\n" + "{date} - Current date as YYYYMMDD\n" + "{time} - Current time as HHMMSS\n\n" + "Default rule:\n" + "{plates} Plates - {sources}.3mf\n\n" + "In individual batch mode, these tokens are calculated separately for each input row.", + ) + + def load_swap_gcode_to_combo(self) -> None: + current = self.swap_gcode_combo.currentData() or self._settings.get("last_swap_gcode") + options = self._settings.get("packing_options", {}) + if isinstance(options, dict): + current = current or options.get("swap_gcode") + self.swap_gcode_combo.clear() + files = list_swap_gcode_files(default_swap_gcode_dir()) + for path in files: + self.swap_gcode_combo.addItem(path.name, str(path)) + if current: + index = self.swap_gcode_combo.findData(str(current)) + if index < 0: + index = self.swap_gcode_combo.findText(Path(str(current)).name) + if index >= 0: + self.swap_gcode_combo.setCurrentIndex(index) + if not files: + self.log.append(f"No swap G-code files found in {default_swap_gcode_dir()}") + + def open_path(self, path: Path) -> None: + target = path if path.exists() else path.parent + target.parent.mkdir(parents=True, exist_ok=True) + if sys.platform.startswith("win"): + os.startfile(str(target)) # type: ignore[attr-defined] + elif sys.platform == "darwin": + subprocess.run(["open", str(target)], check=False) + else: + subprocess.run(["xdg-open", str(target)], check=False) + + def open_swap_gcode_folder(self) -> None: + folder = default_swap_gcode_dir() + folder.mkdir(parents=True, exist_ok=True) + self.open_path(folder) + + def open_patch_config(self) -> None: + path = default_patch_config_path() + if not path.exists(): + QMessageBox.information(self, APP_NAME, f"Patch config does not exist yet:\n{path}") + return + self.open_path(path) + + def add_files(self) -> None: + files, _ = QFileDialog.getOpenFileNames(self, "Add 3MF files", "", "3MF files (*.3mf);;All files (*)") + self.add_paths([Path(file_name) for file_name in files]) + + def add_paths(self, paths: list[Path]) -> None: + expanded: list[Path] = [] + for path in paths: + if path.is_dir(): + expanded.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf")) + elif path.is_file() and path.suffix.lower() == ".3mf": + expanded.append(path) + if not expanded: + return + existing = {self.table.item(row, 0).text() for row in range(self.table.rowCount()) if self.table.item(row, 0)} + added = 0 + skipped = 0 + for path in expanded: + normalized = str(path.resolve()) + if self.skip_duplicates_check.isChecked() and normalized in existing: + skipped += 1 + continue + if self.add_file_row(normalized, self.default_copies_spin.value()): + existing.add(normalized) + added += 1 + else: + skipped += 1 + if added or skipped: + self.log.append(f"Added {added} input file(s). Skipped {skipped}.") + self.update_total_summary() + self.update_output_preview() + + def add_file_row(self, file_name: str, copies: int) -> bool: + path = Path(file_name) + try: + summary = read_3mf_summary(path) + except Exception as exc: + self.log.append(f"Skipped {path}: {exc}") + return False + row = self.table.rowCount() + self._updating_table = True + try: + self.table.insertRow(row) + path_item = QTableWidgetItem(str(path)) + path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable) + path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary)) + path_item.setToolTip(self.base_summary_tooltip(summary)) + copies_item = QTableWidgetItem(str(max(1, int(copies)))) + copies_item.setTextAlignment(Qt.AlignCenter) + time_item = QTableWidgetItem("") + filament_item = QTableWidgetItem("") + for item in (time_item, filament_item): + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.table.setItem(row, 0, path_item) + self.table.setItem(row, 1, copies_item) + self.table.setItem(row, 2, time_item) + self.table.setItem(row, 3, filament_item) + self.update_row_stats(row) + finally: + self._updating_table = False + return True + + def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]: + return { + "source_3mf": str(summary.source_3mf), + "plate_count": summary.plate_count, + "prediction_seconds": summary.prediction_seconds, + "weight_grams": summary.weight_grams, + "filament_used_m": summary.filament_used_m, + "filament_used_g": summary.filament_used_g, + } + + def base_summary_tooltip(self, summary: ThreeMfSummary) -> str: + return ( + f"Base plates: {summary.plate_count}\n" + f"Base time: {format_duration(summary.prediction_seconds)}\n" + f"Base filament: {format_filament(summary.weight_grams, summary.filament_used_m)}" + ) + + def get_row_copies(self, row: int) -> int: + item = self.table.item(row, 1) + if item is None: + return 1 + try: + return max(1, int(item.text().strip())) + except Exception: + return 1 + + def set_row_copies(self, row: int, copies: int) -> None: + item = self.table.item(row, 1) + if item is not None: + item.setText(str(max(1, int(copies)))) + + def update_row_stats(self, row: int) -> None: + path_item = self.table.item(row, 0) + if path_item is None: + return + data = path_item.data(SUMMARY_ROLE) + if isinstance(data, ThreeMfSummary): + summary = data + elif isinstance(data, dict): + summary = three_mf_summary_from_mapping(data) + else: + return + copies = self.get_row_copies(row) + total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies + total_weight = None if summary.weight_grams is None else summary.weight_grams * copies + total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies + self.table.item(row, 2).setText(format_duration(total_prediction)) + self.table.item(row, 3).setText(format_filament(total_weight, total_used_m)) + + def on_table_item_changed(self, item: QTableWidgetItem) -> None: + if self._updating_table: + return + if item.column() == 1: + self._updating_table = True + try: + copies = self.get_row_copies(item.row()) + item.setText(str(copies)) + self.update_row_stats(item.row()) + finally: + self._updating_table = False + self.update_total_summary() + self.update_output_preview() + + def selected_rows(self) -> list[int]: + return sorted({index.row() for index in self.table.selectedIndexes()}) + + def selected_row(self) -> int | None: + rows = self.selected_rows() + return rows[0] if rows else None + + def remove_selected(self) -> None: + rows = sorted(self.selected_rows(), reverse=True) + for row in rows: + self.table.removeRow(row) + self.update_total_summary() + self.update_output_preview() + + def remove_all(self) -> None: + self.table.setRowCount(0) + self.update_total_summary() + self.update_output_preview() + + def move_selected(self, delta: int) -> None: + row = self.selected_row() + if row is None: + return + new_row = row + delta + if new_row < 0 or new_row >= self.table.rowCount(): + return + row_values: list[tuple[str, Any, Any, str]] = [] + for col in range(self.table.columnCount()): + item = self.table.item(row, col) + row_values.append((item.text() if item else "", item.flags() if item else Qt.ItemIsEnabled, item.data(SUMMARY_ROLE) if item else None, item.toolTip() if item else "")) + self._updating_table = True + try: + self.table.removeRow(row) + self.table.insertRow(new_row) + for col, (text, flags, data, tooltip) in enumerate(row_values): + item = QTableWidgetItem(text) + item.setFlags(flags) + if col in {1}: + item.setTextAlignment(Qt.AlignCenter) + elif col in {2, 3}: + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + if data is not None: + item.setData(SUMMARY_ROLE, data) + if tooltip: + item.setToolTip(tooltip) + self.table.setItem(new_row, col, item) + finally: + self._updating_table = False + self.table.selectRow(new_row) + self.update_total_summary() + self.update_output_preview() + + def apply_default_copies_to_selected(self) -> None: + rows = self.selected_rows() + if not rows: + return + self._updating_table = True + try: + for row in rows: + self.set_row_copies(row, self.default_copies_spin.value()) + self.update_row_stats(row) + finally: + self._updating_table = False + self.update_total_summary() + self.update_output_preview() + + def choose_output_dir(self) -> None: + start_dir = self.output_dir_edit.text().strip() + if not start_dir and self.table.rowCount() > 0 and self.table.item(0, 0): + start_dir = str(Path(self.table.item(0, 0).text()).parent) + directory = QFileDialog.getExistingDirectory(self, "Choose output directory", start_dir or "") + if directory: + self.output_dir_edit.setText(directory) + + def collect_jobs(self) -> list[PlateJob]: + jobs: list[PlateJob] = [] + for row in range(self.table.rowCount()): + path_item = self.table.item(row, 0) + if path_item is None: + continue + jobs.append(PlateJob(Path(path_item.text()), self.get_row_copies(row))) + return jobs + + def summary_for_path(self, path: Path) -> ThreeMfSummary: + normalized = str(path) + for row in range(self.table.rowCount()): + item = self.table.item(row, 0) + if item is not None and item.text() == normalized: + data = item.data(SUMMARY_ROLE) + if isinstance(data, ThreeMfSummary): + return data + if isinstance(data, dict): + return three_mf_summary_from_mapping(data) + return read_3mf_summary(path) + + def output_naming_options(self) -> OutputNamingOptions: + return OutputNamingOptions( + output_directory=self.output_dir_edit.text().strip(), + filename_rule=self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN, + ) + + def current_total_summary(self) -> OutputSummary: + return summarize_jobs_for_output(self.collect_jobs(), self.summary_for_path) + + def update_total_summary(self) -> None: + summary = self.current_total_summary() + self.total_summary_label.setText( + "Total: " + f"{summary.plate_count} plate(s) | " + f"Time: {format_duration(summary.prediction_seconds)} | " + f"Filament: {format_filament(summary.weight_grams, summary.filament_used_m)}" + ) + + def update_output_preview(self) -> None: + jobs = self.collect_jobs() + if not jobs: + self.output_preview_label.setText("Output path preview: -") + return + try: + if self.individual_batch_check.isChecked(): + first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path) + self.output_preview_label.setText( + f"Individual batch preview: {first_path} | {len(jobs)} output file(s)" + ) + else: + output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) + self.output_preview_label.setText(f"Output path preview: {output_path}") + except Exception as exc: + self.output_preview_label.setText(f"Output path preview: {exc}") + + def build_options_for_output(self, output_path: Path) -> BuildOptions: + swap_gcode_path = self.swap_gcode_combo.currentData() + if not swap_gcode_path: + raise ValueError("Please put at least one swap G-code file in the swap_gcode folder and select it.") + cool_bed_temp = self.cool_bed_spin.value() if self.bed_cooldown_check.isChecked() else None + return BuildOptions( + swap_gcode=Path(swap_gcode_path), + output_3mf=output_path, + cool_bed_temp=cool_bed_temp, + wait_after_eject_seconds=self.wait_spin.value(), + show_plate_number=self.show_plate_number_check.isChecked(), + swap_after_final=self.swap_final_check.isChecked(), + metadata_mode=self.metadata_combo.currentData(), + apply_gcode_patches=self.patch_check.isChecked(), + ) + + def log_build_result(self, result: Any) -> None: + self.log.append(f"Output: {result.output_3mf}") + self.log.append(f"Plates: {result.plate_count}") + self.log.append(f"Estimated source time: {format_duration(result.total_prediction_seconds)}") + self.log.append(f"Estimated source filament: {format_filament(result.total_weight_grams)}") + self.log.append(f"G-code MD5: {result.gcode_md5}") + + def build_output(self) -> None: + jobs = self.collect_jobs() + if not jobs: + QMessageBox.warning(self, APP_NAME, "Please add at least one 3MF file.") + return + try: + self.save_current_settings() + if self.individual_batch_check.isChecked(): + self.build_individual_outputs(jobs) + else: + self.build_combined_output(jobs) + except Exception as exc: + QMessageBox.critical(self, APP_NAME, str(exc)) + self.log.append(f"Error: {exc}") + return + + def build_combined_output(self, jobs: list[PlateJob]) -> None: + output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path) + options = self.build_options_for_output(output_path) + result = build_packed_3mf(jobs, options) + self.log_build_result(result) + QMessageBox.information(self, APP_NAME, "The packed 3MF file was created.") + if self.clear_after_build_check.isChecked(): + self.remove_all() + + def build_individual_outputs(self, jobs: list[PlateJob]) -> None: + used_paths: set[Path] = set() + success_count = 0 + errors: list[str] = [] + for job in jobs: + try: + output_path = make_unique_for_run( + resolve_output_path([job], self.output_naming_options(), self.summary_for_path), + used_paths, + ) + options = self.build_options_for_output(output_path) + result = build_packed_3mf([job], options) + self.log_build_result(result) + success_count += 1 + except Exception as exc: + errors.append(f"{job.source_3mf.name}: {exc}") + self.log.append(f"Error building {job.source_3mf}: {exc}") + if errors: + QMessageBox.warning( + self, + APP_NAME, + f"Built {success_count} file(s), but {len(errors)} file(s) failed.\n\n" + + "\n".join(errors[:8]) + + ("\n..." if len(errors) > 8 else ""), + ) + return + QMessageBox.information(self, APP_NAME, f"Created {success_count} packed 3MF file(s).") + if self.clear_after_build_check.isChecked(): + self.remove_all() + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() + return + super().dragEnterEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: + urls: list[QUrl] = event.mimeData().urls() + paths = [Path(url.toLocalFile()) for url in urls if url.toLocalFile()] + self.add_paths(paths) + event.acceptProposedAction() + + def closeEvent(self, event: Any) -> None: + self.save_current_settings() + super().closeEvent(event) + + +def main() -> int: + app = QApplication(sys.argv) + app.setFont(QFont("Segoe UI", 10)) + window = MainWindow() + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/a1_swap_mod_packer/metadata.py b/a1_swap_mod_packer/metadata.py new file mode 100644 index 0000000..64aaa0e --- /dev/null +++ b/a1_swap_mod_packer/metadata.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path +from typing import Iterable +from xml.etree import ElementTree as ET + +from .archive import GCODE_MEMBER_RE, list_gcode_members +from .models import BuildOptions, BuildSummary, PlateJob, PlateSource, ThreeMfSummary + + +def read_slice_plate_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]: + try: + data = archive.read("Metadata/slice_info.config") + except KeyError: + return {} + root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) + result: dict[int, dict[str, str]] = {} + for plate in root.findall("plate"): + plate_data: dict[str, str] = {} + for metadata in plate.findall("metadata"): + key = metadata.attrib.get("key") + value = metadata.attrib.get("value") + if key is not None and value is not None: + plate_data[key] = value + index_text = plate_data.get("index") + try: + index = int(index_text) if index_text else len(result) + 1 + except ValueError: + index = len(result) + 1 + result[index] = plate_data + return result + + +def read_filament_metadata(archive: zipfile.ZipFile) -> dict[int, dict[str, str]]: + try: + data = archive.read("Metadata/slice_info.config") + except KeyError: + return {} + root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) + result: dict[int, dict[str, str]] = {} + for plate in root.findall("plate"): + plate_index = 1 + for metadata in plate.findall("metadata"): + if metadata.attrib.get("key") == "index": + try: + plate_index = int(metadata.attrib.get("value", "1")) + except ValueError: + plate_index = 1 + break + filament = plate.find("filament") + if filament is not None: + result[plate_index] = dict(filament.attrib) + return result + + +def safe_float(value: str | None) -> float | None: + if value is None: + return None + try: + return float(value) + except ValueError: + return None + + +def _sum_optional(values: Iterable[float | None]) -> float | None: + total = 0.0 + found = False + for value in values: + if value is not None: + total += value + found = True + return total if found else None + + +def read_3mf_summary(source_3mf: Path) -> ThreeMfSummary: + with zipfile.ZipFile(source_3mf, "r") as archive: + members = list_gcode_members(archive) + if not members: + raise ValueError(f"No Metadata/plate_N.gcode member was found in {source_3mf}") + plate_metadata = read_slice_plate_metadata(archive) + filament_metadata = read_filament_metadata(archive) + + predictions: list[float | None] = [] + weights: list[float | None] = [] + used_m_values: list[float | None] = [] + used_g_values: list[float | None] = [] + for member in members: + match = GCODE_MEMBER_RE.match(member) + plate_index = int(match.group(1)) if match else 1 + metadata = plate_metadata.get(plate_index, {}) + filament = filament_metadata.get(plate_index, {}) + prediction = safe_float(metadata.get("prediction")) + weight = safe_float(metadata.get("weight")) + used_m = safe_float(filament.get("used_m")) + used_g = safe_float(filament.get("used_g")) + predictions.append(prediction) + weights.append(weight if weight is not None else used_g) + used_m_values.append(used_m) + used_g_values.append(used_g if used_g is not None else weight) + + return ThreeMfSummary( + source_3mf=source_3mf, + plate_count=len(members), + prediction_seconds=_sum_optional(predictions), + weight_grams=_sum_optional(weights), + filament_used_m=_sum_optional(used_m_values), + filament_used_g=_sum_optional(used_g_values), + ) + + +def multiply_summary(summary: ThreeMfSummary, copies: int) -> BuildSummary: + multiplier = max(1, int(copies)) + return BuildSummary( + plate_count=summary.plate_count * multiplier, + total_prediction_seconds=None if summary.prediction_seconds is None else summary.prediction_seconds * multiplier, + total_weight_grams=None if summary.weight_grams is None else summary.weight_grams * multiplier, + total_filament_used_m=None if summary.filament_used_m is None else summary.filament_used_m * multiplier, + total_filament_used_g=None if summary.filament_used_g is None else summary.filament_used_g * multiplier, + ) + + +def summarize_jobs(jobs: Iterable[PlateJob]) -> BuildSummary: + plate_count = 0 + predictions: list[float | None] = [] + weights: list[float | None] = [] + used_m_values: list[float | None] = [] + used_g_values: list[float | None] = [] + for job in jobs: + summary = multiply_summary(read_3mf_summary(job.source_3mf), job.copies) + plate_count += summary.plate_count + predictions.append(summary.total_prediction_seconds) + weights.append(summary.total_weight_grams) + used_m_values.append(summary.total_filament_used_m) + used_g_values.append(summary.total_filament_used_g) + return BuildSummary( + plate_count=plate_count, + total_prediction_seconds=_sum_optional(predictions), + total_weight_grams=_sum_optional(weights), + total_filament_used_m=_sum_optional(used_m_values), + total_filament_used_g=_sum_optional(used_g_values), + ) + + +def update_first_slice_info(base_data: bytes, sources: list[PlateSource], options: BuildOptions) -> bytes: + if options.metadata_mode == "source": + return base_data + try: + root = ET.fromstring(base_data.decode("utf-8-sig", errors="replace")) + except Exception: + return base_data + total_prediction = sum(value for value in (source.prediction_seconds for source in sources) if value is not None) + total_weight = sum(value for value in (source.weight_grams for source in sources) if value is not None) + total_used_m = sum(value for value in (source.filament_used_m for source in sources) if value is not None) + total_used_g = sum(value for value in (source.filament_used_g for source in sources) if value is not None) + first_plate = root.find("plate") + if first_plate is None: + return base_data + for metadata in first_plate.findall("metadata"): + key = metadata.attrib.get("key") + if key == "prediction" and total_prediction > 0: + metadata.set("value", str(int(round(total_prediction)))) + elif key == "weight" and total_weight > 0: + metadata.set("value", f"{total_weight:.2f}") + filament = first_plate.find("filament") + if filament is not None: + if total_used_m > 0: + filament.set("used_m", f"{total_used_m:.2f}") + if total_used_g > 0: + filament.set("used_g", f"{total_used_g:.2f}") + return ET.tostring(root, encoding="utf-8", xml_declaration=True) diff --git a/a1_swap_mod_packer/models.py b/a1_swap_mod_packer/models.py new file mode 100644 index 0000000..ec0ba28 --- /dev/null +++ b/a1_swap_mod_packer/models.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +DEFAULT_INSERT_BEFORE_MARKER = ";=====printer finish sound=========" + +MetadataMode = Literal["source", "sum"] +LineEnding = Literal["lf", "crlf"] + + +@dataclass(frozen=True) +class PlateJob: + source_3mf: Path + copies: int = 1 + + +@dataclass(frozen=True) +class GcodePatchRule: + name: str + find: str + replace: str + flag: str = "" + enabled: bool = True + max_count: int = 1 + + +@dataclass(frozen=True) +class GcodePatchConfig: + rules: tuple[GcodePatchRule, ...] + insert_before_marker: str = DEFAULT_INSERT_BEFORE_MARKER + + +@dataclass(frozen=True) +class BuildOptions: + swap_gcode: Path | str + output_3mf: Path + cool_bed_temp: int | None = 45 + wait_after_eject_seconds: int = 45 + show_plate_number: bool = True + swap_after_final: bool = True + metadata_mode: MetadataMode = "source" + line_ending: LineEnding = "crlf" + add_preview_label: bool = True + apply_gcode_patches: bool = True + swap_gcode_dir: Path | None = None + + +@dataclass +class PlateSource: + source_3mf: Path + member_name: str + gcode_text: str + prediction_seconds: float | None = None + weight_grams: float | None = None + filament_used_m: float | None = None + filament_used_g: float | None = None + + +@dataclass(frozen=True) +class ThreeMfSummary: + source_3mf: Path + plate_count: int + prediction_seconds: float | None = None + weight_grams: float | None = None + filament_used_m: float | None = None + filament_used_g: float | None = None + + +@dataclass(frozen=True) +class BuildSummary: + plate_count: int + total_prediction_seconds: float | None + total_weight_grams: float | None + total_filament_used_m: float | None + total_filament_used_g: float | None + + +@dataclass +class BuildResult: + output_3mf: Path + plate_count: int + total_prediction_seconds: float | None + total_weight_grams: float | None + gcode_md5: str diff --git a/a1_swap_mod_packer/patches.py b/a1_swap_mod_packer/patches.py new file mode 100644 index 0000000..33e0c20 --- /dev/null +++ b/a1_swap_mod_packer/patches.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import configparser +from pathlib import Path + +from .gcode import normalize_newlines +from .models import DEFAULT_INSERT_BEFORE_MARKER, GcodePatchConfig, GcodePatchRule +from .paths import default_patch_config_path + + +def parse_bool(value: str | None, default: bool = True) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on", "enabled"} + + +def parse_patch_config(path: Path | None = None) -> GcodePatchConfig: + config_path = path or default_patch_config_path() + if not config_path.exists(): + return GcodePatchConfig(rules=()) + parser = configparser.ConfigParser(interpolation=None, strict=False) + parser.optionxform = str + parser.read(config_path, encoding="utf-8-sig") + + insert_before_marker = DEFAULT_INSERT_BEFORE_MARKER + if parser.has_section("swap"): + insert_before_marker = parser.get("swap", "insert_before_marker", fallback=insert_before_marker).strip().strip('"') + + rules: list[GcodePatchRule] = [] + for section in parser.sections(): + if section.lower().startswith("patch."): + enabled = parse_bool(parser.get(section, "enabled", fallback="true"), True) + find = parser.get(section, "find", fallback="").strip().strip('"') + replace = parser.get(section, "replace", fallback="").strip().strip('"') + flag = parser.get(section, "flag", fallback="").strip().strip('"') + max_count_text = parser.get(section, "max_count", fallback="1").strip() + try: + max_count = max(0, int(max_count_text)) + except ValueError: + max_count = 1 + if find and enabled: + rules.append(GcodePatchRule(section.split(".", 1)[1], find, replace, flag, enabled, max_count)) + + if parser.has_section("Gcode"): + gcode = parser["Gcode"] + base_names = sorted( + key[:-5] + for key in gcode.keys() + if key.startswith("Postion") and key.endswith("_flag") + ) + for base in base_names: + find = gcode.get(base, "").strip().strip('"') + replace = gcode.get(f"{base}_edit", "").strip().strip('"') + flag = gcode.get(f"{base}_flag", "").strip().strip('"') + if find and replace: + rules.append(GcodePatchRule(base, find, replace, flag, True, 1)) + marker = gcode.get("FinishFlag", "").strip().strip('"') + if marker: + insert_before_marker = marker + return GcodePatchConfig(tuple(rules), insert_before_marker) + + +def apply_patch_rule(text: str, rule: GcodePatchRule) -> str: + if not rule.enabled or not rule.find or rule.max_count == 0: + return text + lines = normalize_newlines(text).split("\n") + start_index = 0 + if rule.flag: + for index, line in enumerate(lines): + if line.strip() == rule.flag or rule.flag in line: + start_index = index + 1 + break + replacements = 0 + for index in range(start_index, len(lines)): + if lines[index].strip() == rule.find or lines[index] == rule.find: + lines[index] = rule.replace + replacements += 1 + if replacements >= rule.max_count: + break + return "\n".join(lines) + + +def apply_gcode_patches(text: str, config: GcodePatchConfig) -> str: + patched = normalize_newlines(text) + for rule in config.rules: + patched = apply_patch_rule(patched, rule) + return patched diff --git a/a1_swap_mod_packer/paths.py b/a1_swap_mod_packer/paths.py new file mode 100644 index 0000000..742bddf --- /dev/null +++ b/a1_swap_mod_packer/paths.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +APP_DIR_NAME = "a1_swap_mod_packer" +SWAP_GCODE_DIR_NAME = "swap_gcode" +PATCH_CONFIG_FILE_NAME = "gcode_patches.ini" + + +def program_root() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parents[1] + + +def default_swap_gcode_dir() -> Path: + return program_root() / SWAP_GCODE_DIR_NAME + + +def default_patch_config_path() -> Path: + return program_root() / PATCH_CONFIG_FILE_NAME + + +def user_settings_path() -> Path: + return program_root() / "settings.json" diff --git a/a1_swap_mod_packer/planning.py b/a1_swap_mod_packer/planning.py new file mode 100644 index 0000000..2e838e2 --- /dev/null +++ b/a1_swap_mod_packer/planning.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Callable, Mapping, Sequence + +from .metadata import read_3mf_summary +from .models import PlateJob, ThreeMfSummary + +DEFAULT_OUTPUT_PATTERN = "{plates} Plates - {sources}.3mf" + +SummaryResolver = Callable[[Path], ThreeMfSummary] + + +@dataclass(frozen=True) +class OutputSummary: + plate_count: int + copy_count: int + prediction_seconds: float | None = None + weight_grams: float | None = None + filament_used_m: float | None = None + + +@dataclass(frozen=True) +class OutputNamingOptions: + output_directory: Path | str | None = None + filename_rule: str = DEFAULT_OUTPUT_PATTERN + + +class SafeFormatDict(dict): + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def three_mf_summary_from_mapping(data: Mapping[str, object]) -> ThreeMfSummary: + source_value = data.get("source_3mf") + return ThreeMfSummary( + source_3mf=Path(str(source_value)) if source_value is not None else Path(), + plate_count=int(data.get("plate_count") or 0), + prediction_seconds=_optional_float(data.get("prediction_seconds")), + weight_grams=_optional_float(data.get("weight_grams")), + filament_used_m=_optional_float(data.get("filament_used_m")), + filament_used_g=_optional_float(data.get("filament_used_g")), + ) + + +def summarize_jobs_for_output( + jobs: Sequence[PlateJob], + summary_resolver: SummaryResolver = read_3mf_summary, +) -> OutputSummary: + plate_count = 0 + copy_count = 0 + prediction_total = 0.0 + prediction_found = False + weight_total = 0.0 + weight_found = False + used_m_total = 0.0 + used_m_found = False + for job in jobs: + summary = summary_resolver(job.source_3mf) + copies = max(1, int(job.copies)) + copy_count += copies + plate_count += summary.plate_count * copies + if summary.prediction_seconds is not None: + prediction_total += summary.prediction_seconds * copies + prediction_found = True + if summary.weight_grams is not None: + weight_total += summary.weight_grams * copies + weight_found = True + if summary.filament_used_m is not None: + used_m_total += summary.filament_used_m * copies + used_m_found = True + return OutputSummary( + plate_count=plate_count, + copy_count=copy_count, + prediction_seconds=prediction_total if prediction_found else None, + weight_grams=weight_total if weight_found else None, + filament_used_m=used_m_total if used_m_found else None, + ) + + +def sanitize_filename(file_name: str) -> str: + sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", file_name).strip() + sanitized = sanitized.rstrip(". ") + return sanitized or "packed.3mf" + + +def resolve_output_path( + jobs: Sequence[PlateJob], + naming: OutputNamingOptions, + summary_resolver: SummaryResolver = read_3mf_summary, + now: datetime | None = None, +) -> Path: + if not jobs: + raise ValueError("No input 3MF file was provided.") + summary = summarize_jobs_for_output(jobs, summary_resolver) + first_input = jobs[0].source_3mf + stems = [job.source_3mf.stem for job in jobs] + unique_stems = list(dict.fromkeys(stems)) + source_token = first_input.stem + sources_token = source_token if len(unique_stems) == 1 else f"{source_token}_and_{len(unique_stems) - 1}_more" + timestamp = now or datetime.now() + tokens = SafeFormatDict( + source=source_token, + sources=sources_token, + plates=summary.plate_count, + copies=summary.copy_count, + date=timestamp.strftime("%Y%m%d"), + time=timestamp.strftime("%H%M%S"), + ) + pattern = naming.filename_rule.strip() or DEFAULT_OUTPUT_PATTERN + file_name = sanitize_filename(pattern.format_map(tokens)) + if not file_name.lower().endswith(".3mf"): + file_name += ".3mf" + output_dir_text = "" if naming.output_directory is None else str(naming.output_directory).strip() + output_dir = Path(output_dir_text) if output_dir_text else first_input.parent + return output_dir / file_name + + +def make_unique_for_run(path: Path, used_paths: set[Path]) -> Path: + resolved_key = path.resolve(strict=False) + if resolved_key not in used_paths: + used_paths.add(resolved_key) + return path + stem = path.stem + suffix = path.suffix + parent = path.parent + index = 2 + while True: + candidate = parent / f"{stem}_{index}{suffix}" + candidate_key = candidate.resolve(strict=False) + if candidate_key not in used_paths: + used_paths.add(candidate_key) + return candidate + index += 1 + + +def _optional_float(value: object) -> float | None: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/gcode_patches.ini b/gcode_patches.ini new file mode 100644 index 0000000..648c6ff --- /dev/null +++ b/gcode_patches.ini @@ -0,0 +1,11 @@ +# Editable G-code patch rules for A1 Swap Mod Packer. + +[swap] +insert_before_marker = ;=====printer finish sound========= + +[patch.a1_start_y] +enabled = true +flag = G0 X128 F30000 +find = G0 Y254 F3000 +replace = G0 Y250 F3000 ;Patched +max_count = 1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..02cca77 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6 +Pillow>=10.0 diff --git a/run_cli.py b/run_cli.py new file mode 100644 index 0000000..f696bbb --- /dev/null +++ b/run_cli.py @@ -0,0 +1,4 @@ +from a1_swap_mod_packer.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/run_gui.py b/run_gui.py new file mode 100644 index 0000000..43e2be2 --- /dev/null +++ b/run_gui.py @@ -0,0 +1,4 @@ +from a1_swap_mod_packer.gui import main + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/swap_gcode/LX_0.07.22.gcode b/swap_gcode/LX_0.07.22.gcode new file mode 100644 index 0000000..c8de063 --- /dev/null +++ b/swap_gcode/LX_0.07.22.gcode @@ -0,0 +1,100 @@ +;start change plate + +G28 Y +G1 Y262 F1200 +G91 +G380 S3 Z-25 F1200 +G380 S2 Z85 F1200 +G380 S3 Z-20 F1200 +G380 S2 Z85 F1200 +G380 S3 Z-20 F1200 +G380 S2 Z85 F1200 +G380 S3 Z-20 F1200 +G380 S2 Z85 F1200 +G1 Z-5 F1200 +G90; + +G4 P2000 + +G1 Y15 F2000 +G1 Y150 F1200 +G1 Y40 F1200 +;G1 Y260 F3000 +;The platform ensures that new plate can be added properly even when no plate are available. +G380 S2 Y264 F2000 + +G91; +;G380 S3 Z-55 F1200 +G1 Z-55 F1200 +G90; + +G1 Y120 F4000 + +G28 Y +;Avoiding the use of the G380 command is to prevent failure in reaching the correct position. +G1 Y-1 F1200 +;G380 S3 Y-1 F1200 + +G4 P500 + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y3 F100 +G1 Y0 F10000 +G4 P500 +G1 Y3.5 F100 +G1 Y0 F5000 +;end Shake-triggered plate replacement + +G1 Y235 F1200 +G1 Y110 F5000 +G1 Y200 F1200 +;G1 Y266 F1500 +G380 S2 Y264 F2000 + +;After retracting the build plate to the frontmost position, dwell for 1 second. +G4 P1000 + +G1 Y40 F4000 +G1 Y-2 F5000 + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y3 F100 +G1 Y0 F10000 +G4 P500 +G1 Y3.5 F100 +G1 Y0 F5000 + +;G1 Y2.5 F100 +;G1 Y0 F8000 +;G4 P1000 +;G1 Y3.5 F100 +;G1 Y0 F5000 +;end Shake-triggered plate replacement + +G1 Y120 F2000 + +;end change plate \ No newline at end of file diff --git a/swap_gcode/LX_1.02.26.gcode b/swap_gcode/LX_1.02.26.gcode new file mode 100644 index 0000000..3841401 --- /dev/null +++ b/swap_gcode/LX_1.02.26.gcode @@ -0,0 +1,115 @@ + +;start change plate + +G28 Y +G1 Y262 F2000 + +M400 ; wait all motion done +M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom + +G91 +G1 Z-35 F1200 +G380 S2 Z95 + +G1 Z-25 +G380 S2 Z90 + +G1 Z-25 +G380 S2 Z90 + +G1 Z-30 +G380 S2 Z95 + +G1 Z-5 +G90 + +G4 P2000 + +G1 Y15 F800 +G1 Y150 F1200 + +;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs. +G1 Y40 F1000 + +;The platform ensures that new plate can be added properly even when no plate are available. +;G1 Y260 F3000 +G380 S2 Y264 F2000 + +G91; +G1 Z-70 F1200 +G90; + +; +G1 Y200 F10000 + +G1 Y25 F4000 + +;Avoiding the use of the G380 command is to prevent failure in reaching the correct position. +G1 Y-1 F20000 +;G380 S3 Y-1 F1200 + +G4 P500 + +G28 Y + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F10000 +G4 P500 +;G1 Y3.5 F100 +;G1 Y0 F5000 +;end Shake-triggered plate replacement + +G1 Y235 F1200 +G1 Y110 F5000 +G1 Y200 F1200 +;G1 Y266 F1500 +G380 S2 Y264 F2000 + +;After retracting the build plate to the frontmost position, dwell for 1 second. +G4 P1000 + +G1 Y25 F4000 +G1 Y-2 F20000 +;G1 Y-2 F1000 + +G4 P500 + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F10000 +G4 P500 + + +;end Shake-triggered plate replacement + +;move to front +G1 Y262 F1500 + +G1 Y120 F2000 + +;end change plate \ No newline at end of file diff --git a/swap_gcode/LX_1.02.26_slow.gcode b/swap_gcode/LX_1.02.26_slow.gcode new file mode 100644 index 0000000..c2b0dd4 --- /dev/null +++ b/swap_gcode/LX_1.02.26_slow.gcode @@ -0,0 +1,115 @@ + +;start change plate + +G28 Y +G1 Y262 F2000 + +M400 ; wait all motion done +M17 Z0.4 ; lower z motor current to reduce impact if there is something in the bottom + +G91 +G1 Z-35 F1200 +G380 S2 Z95 + +G1 Z-25 +G380 S2 Z90 + +G1 Z-25 +G380 S2 Z90 + +G1 Z-30 +G380 S2 Z95 + +G1 Z-5 +G90 + +G4 P2000 + +G1 Y15 F800 +G1 Y150 F1200 + +;This line controls how much distance the tail of the reel hangs. The smaller the value, the less distance it hangs. +G1 Y40 F1000 + +;The platform ensures that new plate can be added properly even when no plate are available. +;G1 Y260 F3000 +G380 S2 Y264 F2000 + +G91; +G1 Z-70 F1200 +G90; + +; +G1 Y200 F10000 + +G1 Y25 F4000 + +;Avoiding the use of the G380 command is to prevent failure in reaching the correct position. +G1 Y-1 F20000 +;G380 S3 Y-1 F1200 + +G4 P500 + +G28 Y + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F10000 +G4 P500 +;G1 Y3.5 F100 +;G1 Y0 F5000 +;end Shake-triggered plate replacement + +G1 Y235 F1200 +G1 Y110 F5000 +G1 Y200 F1200 +;G1 Y266 F1500 +G380 S2 Y264 F2000 + +;After retracting the build plate to the frontmost position, dwell for 1 second. +G4 P1000 + +G1 Y25 F4000 +;G1 Y-2 F20000 +G1 Y-2 F1000 + +G4 P500 + +;start Shake-triggered plate replacement +G1 Y1 F100 +G1 Y0 F20000 +G4 P500 +G1 Y1.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F20000 +G4 P500 +G1 Y2.5 F100 +G1 Y0 F10000 +G4 P500 + + +;end Shake-triggered plate replacement + +;move to front +G1 Y262 F1500 + +G1 Y120 F2000 + +;end change plate \ No newline at end of file