├── .gitignore ├── LICENCE ├── README.md ├── freecad └── fcscript │ ├── __init__.py │ ├── demo │ ├── __init__.py │ ├── v_0_0_1.py │ └── v_0_0_2.py │ ├── init_gui.py │ ├── v_0_0_1.py │ └── v_0_0_2.py ├── package.xml └── screenshot.jpg /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FCScript 2 | 3 | Simple DSL for Macro writers. In other words, a simplified API for macro coders. 4 | 5 | Work In Progress .... 6 | 7 | ![My Image](screenshot.jpg) 8 | 9 | 10 | Demo video: https://www.youtube.com/watch?v=vs939L5Ku2Q 11 | 12 | 13 | ## Usage 14 | 15 | This AddOn is for people interested in writing macros for FreeCAD. The intention is 16 | to provide a simplified API for common operations. 17 | 18 | ## Estimated Progress/TODO: 19 | 20 | - ![Progress](https://progress-bar.dev/50/) Sketcher basic API 21 | - ![Progress](https://progress-bar.dev/30/) Basic GUI API 22 | - ![Progress](https://progress-bar.dev/5/) Dynamic/Expressions API 23 | - ![Progress](https://progress-bar.dev/0/) Draft basic API 24 | - ![Progress](https://progress-bar.dev/0/) PartDesign API 25 | - ![Progress](https://progress-bar.dev/0/) Part API 26 | 27 | ## Install 28 | 29 | - Use FreeCAD Builtin Addon Manager to install this package. 30 | - Or Download this repo as a zip file and unzip it in `$HOME/.FreeCAD/Mod`, then restart FreeCAD. 31 | 32 | ## Documentation 33 | 34 | Once installed and restarted, you can find a demo Macro called: `FCScript_Demo.FCMacro`. This contains examples. 35 | 36 | See: https://github.com/mnesarco/fcscript/blob/main/freecad/fcscript/demo/v_0_0_1.py 37 | 38 | Many of the demos require **LinkStage3** because mainstream FreeCAD does not support multiple solids inside a body. 39 | 40 | ## License 41 | 42 | GPL 3.0 43 | 44 | -------------------------------------------------------------------------------- /freecad/fcscript/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | """ 22 | Small DSL for FreeCAD Macro development. 23 | """ 24 | 25 | __author__ = "Frank David Martinez M" 26 | __copyright__ = "Copyright (c) 2022, Frank David Martinez M. " 27 | __license__ = "GPL" 28 | __version__ = "0.0.1" 29 | __maintainer__ = "Frank David Martinez M. " 30 | __git__ = "https://github.com/mnesarco/fcscript.git" 31 | __status__ = "Development" 32 | -------------------------------------------------------------------------------- /freecad/fcscript/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnesarco/fcscript/bbdf566249578f27e95845cef9b752900e88ddf9/freecad/fcscript/demo/__init__.py -------------------------------------------------------------------------------- /freecad/fcscript/demo/v_0_0_1.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | from math import cos, radians, sin, sqrt 22 | from freecad.fcscript.v_0_0_1 import ( 23 | InputOptions, InputText, InputVector, XBody, XSketch, Vec, Pnt, Quantity, recompute, Dx, Dy, Dz, Expr, 24 | Dialog, InputFloat, InputInt, InputSelectMany, InputSelectOne, 25 | InputBoolean, Icon, Row, Col, TabContainer, Tab, 26 | button, on_event, gq, progress_indicator, selection, 27 | show_error, show_info, show_msgbox, show_warning, 28 | DataObject, App, Gui 29 | ) 30 | 31 | def test1(): 32 | s = XSketch("test1") 33 | 34 | # Triangle 35 | grp = s.create_group() 36 | grp.line_to((30, 30)) 37 | grp.line_to((100, 0)) 38 | grp.close() 39 | 40 | # Triangle 41 | grp.move_to((200,0)) 42 | grp.line_to((250,250)) 43 | grp.line_to((0,250)) 44 | grp.close() 45 | 46 | # SpLine 47 | g2 = s.create_group() 48 | g2.move_to((300,0)) 49 | g2.line_to((0, 50)) 50 | g2.bspline_to((50,50), (50,60), (60,50), (50,70), (70,50), (50,80), (80,50)) 51 | g2.close() 52 | 53 | # SpLine 54 | g3 = s.create_group() 55 | g3.move_to((400,0)) 56 | g3.bspline_to((200,100), (300,200), (300,300)) 57 | g3.move_to((0,0)) 58 | g3.circle(45) 59 | g3.rect_to((20,20)) 60 | g3.move_to((-100,-100)) 61 | g3.rect(50, 100, angle=Quantity('30 deg')) 62 | g3.rect(50, 100) 63 | 64 | recompute() 65 | 66 | def test2_partial(body, plane): 67 | rows, cols = 5, 3 68 | w, h = 25, 10 69 | sx, sy = 15, 5 70 | s = body.sketch(plane=plane, name=plane) 71 | g = s.create_group() 72 | 73 | x, y = g.pos[0], g.pos[1] + 10 74 | x0 = x 75 | for row in range(rows): 76 | for col in range(cols): 77 | g.move_to((x,y)) 78 | g.rect(w, h) 79 | x = x + w + sx 80 | x = x0 81 | y = y + h + sy 82 | 83 | s.pad(10, name=f'Pad{plane}') 84 | recompute() 85 | 86 | 87 | def test2(): 88 | with progress_indicator(): 89 | body = XBody(name="test2") 90 | test2_partial(body, 'XY') 91 | test2_partial(body, 'XZ') 92 | 93 | 94 | def test3(): 95 | with progress_indicator(): 96 | hole = 5 97 | side = 100 98 | 99 | body = XBody(name='test3') 100 | sketch = body.sketch(plane='XY', name='s1') 101 | 102 | path = sketch.create_group() 103 | path.rect_to(Pnt(side, side)) 104 | h1 = Pnt(hole, hole) * 2 105 | hole_dist = side - 4*hole 106 | path.move_to(h1) 107 | path.circle(hole) 108 | path.move_to(h1 + Vec(hole_dist, 0, 0)) 109 | path.circle(hole) 110 | path.move_to(h1 + Vec(0, hole_dist, 0)) 111 | path.circle(hole) 112 | path.move_to(h1 + Vec(hole_dist, hole_dist, 0)) 113 | path.circle(hole) 114 | 115 | sketch.pad(10) 116 | recompute() 117 | 118 | def test4(): 119 | with progress_indicator(): 120 | body = XBody(name='test4') 121 | sketch = body.sketch(plane='XY', name='s2') 122 | path = sketch.create_group() 123 | 124 | path.line_to(Pnt(10,0)) 125 | path.bspline_to(Pnt(15, 0), Pnt(15, 5)) 126 | path.line_to(Pnt(15,20)) 127 | path.bspline_to(Pnt(15, 25), Pnt(10, 25)) 128 | path.line_to(Pnt(0,25)) 129 | path.close() 130 | 131 | path = sketch.create_group() 132 | path.move_to(Pnt(7.5, 7.5)) 133 | path.rect(7, 7, Quantity('45 deg')) 134 | 135 | sketch.pad(30, direction=Vec(0,0,0) + Dy(-1) + Dz(-1)) 136 | recompute() 137 | 138 | 139 | def test5(): 140 | with progress_indicator(): 141 | body = XBody(name='test5') 142 | sketch = body.sketch(plane='XY', name='s1') 143 | path = sketch.create_group() 144 | path.rect_rounded(w=7, h=14, r=2, angle=Quantity('45 deg')) 145 | path.move_to((50,0)) 146 | path.rect_rounded(w=7, h=14, r=2, angle=Expr("0.5 rad")) 147 | sketch.pad(3) 148 | recompute() 149 | 150 | 151 | def test6(): 152 | with progress_indicator(): 153 | body = XBody(name='test6') 154 | sketch = body.sketch(plane='XY', name='s1') 155 | path = sketch.create_group() 156 | path.rect_rounded(w=10, h=10, r=(1,2,1,2), angle=Expr("30 deg")) 157 | path.move_to((50,0)) 158 | path.rect_rounded(w=10, h=10, r=(1,2,1,2), angle=Expr("30 deg")) 159 | sketch.pad(2) 160 | recompute() 161 | 162 | 163 | def test_7_polygons(): 164 | with progress_indicator(): 165 | body = XBody(name='test7') 166 | sketch = body.sketch(plane='XY', name='poly') 167 | path = sketch.create_group() 168 | 169 | def auto(fn, *args, **kwargs): 170 | fn(*args, **kwargs) 171 | 172 | def constrain_pos(fn, constrain, *args, **kwargs): 173 | fn(*args, **kwargs, constrain_pos=constrain) 174 | 175 | auto(path.regular_polygon, 50, 3) 176 | path.move(dx=50) 177 | constrain_pos(path.regular_polygon, True, 50, 4) 178 | path.move(dx=50) 179 | constrain_pos(path.regular_polygon, False, 50, 5) 180 | path.move(dx=50) 181 | constrain_pos(path.regular_polygon, True, 50, 6) 182 | 183 | path.move(dx=50) 184 | constrain_pos(path.regular_polygon, True, 50, 6, angle=Quantity('15 deg')) 185 | recompute() 186 | 187 | 188 | def test_8_hive(): 189 | with progress_indicator(): 190 | body = XBody(name='test8') 191 | sketch = body.sketch(plane='XY', name='s1') 192 | path = sketch.create_group() 193 | size = 10 194 | sep = 1,-1 195 | rows = 6 196 | cols = 4 197 | for row in range(rows): 198 | shift = -0.5 if row % 2 else 0.5 199 | for col in range(cols): 200 | path.move(dx=size+sep[0]) 201 | path.regular_polygon(gq.Radius(size/2.0), 6, constrain_pos=True) 202 | path.move(dx=-(cols - shift)*(size+sep[0]), dy=size+sep[1]) 203 | path.move_to((0, -size/2 -sep[1] -5)) 204 | path.rect_rounded(w=(size+sep[0])*cols+30, h=(size+sep[1])*rows+10, r=3) 205 | sketch.pad(2) 206 | recompute() 207 | 208 | 209 | def test9_diag1(): 210 | with Dialog(title="1st Dialog"): 211 | with Row(): 212 | with Col(): 213 | rad = InputFloat(label="Radius:") 214 | sides = InputInt(label="Sides:") 215 | with Col(): 216 | it = InputInt(label="Iteration:") 217 | conv = InputFloat(label="Convolution:") 218 | 219 | def test10_diag2(): 220 | with Dialog(title="2nd Dialog"): 221 | with Col(): 222 | rad = InputFloat(label="Radius:") 223 | sides = InputInt(label="Sides:") 224 | it = InputInt(label="Iteration:") 225 | conv = InputFloat(label="Convolution:") 226 | active = InputBoolean(label="Active:") 227 | pos = InputVector(label="Pos (Vector):", value=(10,20,30)) 228 | @button(text="Dump") 229 | def btn(): 230 | print(f"rad={rad.value()}, sides={sides.value()}, it={it.value()}, conv={conv.value()}, active={active.value()}") 231 | print(f"Pos={pos.value()}") 232 | 233 | 234 | def test11_diag3(): 235 | with Dialog(title="3rd Dialog"): 236 | rad = InputFloat(label="Radius:") 237 | sides = InputInt(label="Sides:") 238 | it = InputInt(label="Iteration:") 239 | conv = InputFloat(label="Convolution:") 240 | active = InputBoolean(label="Active:") 241 | 242 | 243 | def test12_diag4(): 244 | with Dialog(title="4th Dialog") as dialog: 245 | with Col(): 246 | count = InputInt(label="Count:", value=2) 247 | width = InputFloat(label="Width:", value=4) 248 | height = InputFloat(label="Height:", value=6) 249 | radius = InputFloat(label="Radius:", value=1) 250 | @button(text="Execute") 251 | def execute(): 252 | with progress_indicator("Working..."): 253 | body = XBody(name='test9') 254 | sketch = body.sketch(plane='XY', name='main9') 255 | path = sketch.create_group() 256 | for _ in range(count.value()): 257 | path.rect_rounded(width.value(), height.value(), radius.value()) 258 | path.move(dx=width.value()*1.2) 259 | sketch.pad(3) 260 | recompute() 261 | dialog.close() 262 | 263 | 264 | def test13_diag5(): 265 | with Dialog(title="5th Dialog / Single Select") as dialog: 266 | with Col(): 267 | a = InputSelectOne(label="Part A:") 268 | b = InputSelectOne(label="Part B:") 269 | 270 | @on_event(a.selected) 271 | def example_listener(sel): 272 | print(f"Part A was selected: {sel}") 273 | 274 | @on_event(b.selected) 275 | def example_listener2(sel): 276 | print(f"Part B was selected: {sel}") 277 | 278 | @button(text="Do Something", icon=Icon(':/icons/edit_OK.svg')) 279 | def do_something(): 280 | with selection(a.value(), b.value()): 281 | print(f"Parts: {a.value()} and {b.value()}") 282 | 283 | 284 | def test14_diag6(): 285 | with Dialog(title="6th Dialog / Multi Select") as dialog: 286 | with Col(): 287 | c = InputSelectMany(label="Many Parts:") 288 | 289 | @on_event(c.selected) 290 | def example_listener(sel): 291 | print(f"One Part was selected: {sel}") 292 | 293 | @button(text="Do Something", icon=Icon(':/icons/edit_OK.svg')) 294 | def do_something(): 295 | print("-" * 80) 296 | for sel in c.value(): 297 | print(f"Selected: {sel}") 298 | 299 | 300 | def test15_diag7(): 301 | with Dialog(title="7th Dialog / Tabs") as dialog: 302 | with Col(): 303 | with TabContainer(): 304 | 305 | with Tab('First Tab'): 306 | with Col(): 307 | InputFloat(label="Sample input1:") 308 | InputFloat(label="Sample input2:") 309 | InputFloat(label="Sample input3:") 310 | InputFloat(label="Sample input4:") 311 | 312 | with Tab('Second Tab'): 313 | with Col(): 314 | InputSelectMany(label="Sample select many") 315 | 316 | with Tab('3th Tab'): 317 | with Row(): 318 | @button(text="Btn1") 319 | def do_nothing(): 320 | pass 321 | @button(text="Btn2") 322 | def do_nothing(): 323 | pass 324 | 325 | 326 | def test16_msgboxes(): 327 | show_info("test info") 328 | show_warning("test warn") 329 | show_error("test err") 330 | 331 | show_info("test info", title="aaaa") 332 | show_warning("test warn", title="bbbb") 333 | show_error("test err", title="cccc") 334 | 335 | 336 | def test17_parametric(): 337 | dataObject = DataObject("test17") 338 | dataObject.add_property("App::PropertyFloat", "Radius") 339 | dataObject.add_property("App::PropertyFloat", "Extent") 340 | dataObject.add_property("App::PropertyFloat", "Whatever") 341 | dataObject.Radius = 55 342 | dataObject.Extent = Expr(".Radius*2") 343 | dataObject.Whatever = Expr(".Extent*2.5 + 10") 344 | 345 | 346 | def test18_rounded_rect(): 347 | with Dialog("Test18: Simple Rounded Rect"): 348 | with Col(): 349 | width = InputFloat(label="Width:", value=50) 350 | length = InputFloat(label="Length:", value=50) 351 | height = InputFloat(label="Height:", value=5) 352 | radius = InputFloat(label="Border radius:", value=3) 353 | @button(text="Create") 354 | def create(): 355 | body = XBody(name='test18') 356 | sketch = body.sketch(plane='XY', name='sketch') 357 | path = sketch.create_group() 358 | path.rect_rounded(w=width.value(), h=length.value(), r=radius.value()) 359 | sketch.pad(height.value()) 360 | recompute() 361 | 362 | def test19_options(): 363 | with Dialog("Test19: Options"): 364 | with Col(): 365 | # Key is the option label, Value can be anything 366 | options = { 367 | "Test 18": test18_rounded_rect, 368 | "Test 12": test12_diag4, 369 | "Test 15": test15_diag7 370 | } 371 | sel = InputOptions(options, value=test12_diag4, label="Select One Demo:") 372 | @button(text="Run") 373 | def run(): 374 | data_fn = sel.value() 375 | data_fn() 376 | 377 | 378 | def test20(): 379 | ''''Spiral''' 380 | with progress_indicator("Working..."): 381 | body = XBody(name='test20') 382 | sketch = body.sketch(plane='XY', name='sketch') 383 | path = sketch.create_group() 384 | radius = 100 385 | angle = 0 386 | for t in range(60): 387 | angle = radians(12*t) 388 | x, y = radius * cos(angle), radius * sin(angle) 389 | path.move_to(Pnt(x, y)) 390 | path.circle(radius/12) 391 | radius *= 1.06 392 | sketch.pad(5000) 393 | recompute() 394 | Gui.SendMsgToActiveView("ViewFit") 395 | 396 | 397 | def test21(): 398 | """Generate Part Demo""" 399 | # GUI 400 | with Dialog("Part Demo Changed"): 401 | # Get Input parameters 402 | base_name = InputText(label="Name:", value="test21") 403 | main_radius_input = InputFloat(label="Main Hole Radius:", value=100) 404 | small_radius_input = InputFloat(label="Small Holes Radius:", value=10) 405 | margin_input = InputFloat(label="Distance between Main Hole and small Holes:", value=10) 406 | corner_input = InputFloat(label="Corner Angle Offset:", value=5) 407 | thickness_input = InputFloat(label="Thickness:", value=5) 408 | 409 | # Polar to Cartesian converter 410 | def coords(radius, angle): 411 | angle = radians(angle) 412 | return radius * cos(angle), radius * sin(angle) 413 | 414 | @button(text="Create") 415 | def execute(): 416 | 417 | # Read inputs 418 | main_radius = main_radius_input.value() 419 | small_radius = small_radius_input.value() 420 | margin = margin_input.value() 421 | thickness = thickness_input.value() 422 | dev = corner_input.value() 423 | 424 | # Create base objects 425 | body = XBody(name=base_name.value() or 'test21') 426 | sketch = body.sketch(plane='XY', name='sketch') 427 | path = sketch.create_group() 428 | 429 | # Main hole 430 | path.circle(main_radius) 431 | 432 | # Small Holes 433 | for angle in (90, 210, 330): 434 | path.move_to(coords(main_radius + margin + small_radius, angle)) 435 | path.circle(small_radius) 436 | 437 | # Perimeter bspline 438 | points = [] 439 | outer = main_radius + 2*margin + 2*small_radius 440 | points.append(coords(outer, 90)) 441 | angle = 90 442 | for t in range(3): 443 | points.append(coords(outer, angle + dev)) 444 | points.append(coords(outer-2*margin, angle + 60 - dev)) 445 | points.append(coords(outer-2*margin, angle + 60)) 446 | points.append(coords(outer-2*margin, angle + 60 + dev)) 447 | points.append(coords(outer, angle + 120 - dev)) 448 | angle += 120 449 | points.append(coords(outer, 90)) 450 | points.append(coords(outer, 90)) 451 | path.move_to(coords(outer, 90)) 452 | path.bspline_to(*points) 453 | 454 | sketch.pad(thickness) 455 | recompute() 456 | Gui.SendMsgToActiveView("ViewFit") 457 | 458 | 459 | with Dialog(title="FCScript Demo") as dialog: 460 | if not App.ActiveDocument: 461 | App.newDocument() 462 | with Row(): 463 | with Col(): 464 | @button(text="Test1: Basic Sketch") 465 | def run_test1(): test1() 466 | 467 | @button(text="Test2: Planes (LinkStage3)") 468 | def run_test2(): test2() 469 | 470 | @button(text="Test3: Plate-Holes") 471 | def run_test3(): test3() 472 | 473 | @button(text="Test4: Extrusion") 474 | def run_test4(): test4() 475 | 476 | @button(text="Test5: Rounded Rect") 477 | def run_test5(): test5() 478 | 479 | with Col(): 480 | 481 | @button(text="Test6: Asymmetric Rounded Rect") 482 | def run_test6(): test6() 483 | 484 | @button(text="Test7: Polygons") 485 | def run_test7(): test_7_polygons() 486 | 487 | @button(text="Test8: Hive") 488 | def run_test8(): test_8_hive() 489 | 490 | @button(text="Test11: Dialogs Basic") 491 | def run_test11(): test11_diag3() 492 | 493 | @button(text="Test10: Dialogs Col") 494 | def run_test10(): test10_diag2() 495 | 496 | with Col(): 497 | 498 | @button(text="Test9: Dialogs Row/Col") 499 | def run_test9(): test9_diag1() 500 | 501 | @button(text="Test12: Dialogs Button") 502 | def run_test12(): test12_diag4() 503 | 504 | @button(text="Test13: Dialogs Single Select") 505 | def run_test13(): test13_diag5() 506 | 507 | @button(text="Test14: Dialogs Multi Select") 508 | def run_test14(): test14_diag6() 509 | 510 | @button(text="Test15: Dialogs Tabs") 511 | def run_test15(): test15_diag7() 512 | 513 | with Col(): 514 | 515 | @button(text="Test16: Message Boxes") 516 | def run_test16(): test16_msgboxes() 517 | 518 | @button(text="Test17: Data Object") 519 | def run_test17(): test17_parametric() 520 | 521 | @button(text="Test18: Rounded Rect") 522 | def run_test18(): test18_rounded_rect() 523 | 524 | @button(text="Test19: Options") 525 | def run_test19(): test19_options() 526 | 527 | @button(text="Test20: Spiral (LinkStage3)") 528 | def run_test20(): test20() 529 | 530 | with Col(): 531 | 532 | @button(text="Test21: Part") 533 | def run_test21(): test21() 534 | 535 | -------------------------------------------------------------------------------- /freecad/fcscript/demo/v_0_0_2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | from freecad.fcscript.v_0_0_2 import * 22 | 23 | def test1_wire3D(): 24 | radius = 5 25 | start = 0,0,radius 26 | w = Wire3D(start) 27 | w.add_segment((0,0,100-radius)) 28 | w.add_round_corner((0,100,100), radius) 29 | w.add_round_corner((100,100,100), radius) 30 | w.add_round_corner((0,0,radius), radius) 31 | w.set_to('test1_wire_3d') 32 | 33 | 34 | with Dialog(title="FCScript Demo 0.0.2") as dialog: 35 | if not App.ActiveDocument: 36 | App.newDocument() 37 | with Row(): 38 | with Col(): 39 | @button(text="Test1: Basic Wire in 3D Space") 40 | def run_test1(): test1_wire3D() 41 | -------------------------------------------------------------------------------- /freecad/fcscript/init_gui.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | from freecad.fcscript import __version__ as fs_version 22 | import pathlib, shutil, os, re 23 | import FreeCAD as App 24 | 25 | auto_update_macro = True 26 | 27 | if auto_update_macro: 28 | user_macro_dir = pathlib.Path(App.getUserMacroDir()) 29 | demo_macro_dir = pathlib.Path(pathlib.Path(__file__).parent, "demo") 30 | pattern = re.compile(r'^v_(\d+)_(\d+)_(\d+).py$') 31 | for file in os.listdir(demo_macro_dir): 32 | if pattern.match(file): 33 | src = pathlib.Path(demo_macro_dir, file) 34 | target = pathlib.Path(user_macro_dir, f"FCScript_Demo_{file}") 35 | print(f"FCScript Demo Macro init: {src} -> {target}") 36 | shutil.copy2(src, target) 37 | -------------------------------------------------------------------------------- /freecad/fcscript/v_0_0_1.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | # └────────────────────────────────────────────────────────────────────────────┘ 22 | # [SECTION] Common Builtin Imports 23 | # ┌────────────────────────────────────────────────────────────────────────────┐ 24 | 25 | from cmath import exp 26 | from contextlib import contextmanager 27 | from enum import Enum 28 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Union 29 | import math 30 | import sys 31 | import threading 32 | 33 | try: 34 | from typing import Protocol, runtime_checkable 35 | except: 36 | from typing_extensions import Protocol, runtime_checkable 37 | 38 | 39 | # └────────────────────────────────────────────────────────────────────────────┘ 40 | # [SECTION] FreeCAD Imports 41 | # ┌────────────────────────────────────────────────────────────────────────────┐ 42 | 43 | from FreeCAD import Base 44 | import FreeCAD as App 45 | import Part 46 | import Sketcher 47 | from ProfileLib import RegularPolygon 48 | import FreeCADGui as Gui 49 | 50 | 51 | # └────────────────────────────────────────────────────────────────────────────┘ 52 | # [SECTION] [Lang] Typing 53 | # ┌────────────────────────────────────────────────────────────────────────────┐ 54 | 55 | @runtime_checkable 56 | class ObjectWithOrigin(Protocol): 57 | @property 58 | def Origin(self) -> App.DocumentObject: 59 | ... 60 | 61 | 62 | # └────────────────────────────────────────────────────────────────────────────┘ 63 | # [SECTION] [FreeCAD] Aliases 64 | # ┌────────────────────────────────────────────────────────────────────────────┐ 65 | 66 | #: Point/Vector alias 67 | Pnt = Base.Vector 68 | 69 | #: Vector alias 70 | Vec = Base.Vector 71 | 72 | #: App.Rotation alias 73 | Rotation = App.Rotation 74 | 75 | #: Quantity converter from string 76 | Quantity = App.Units.Quantity 77 | 78 | #: Commands 79 | command = Gui.runCommand 80 | 81 | # └────────────────────────────────────────────────────────────────────────────┘ 82 | # [SECTION] [Constants] Common constants 83 | # ┌────────────────────────────────────────────────────────────────────────────┐ 84 | 85 | #: Geometry start point (except circles or ellipses) 86 | GeomStart = 1 87 | 88 | #: Geometry end point (except circles or ellipses) 89 | GeomEnd = 2 90 | 91 | #: Geometry center point (only circles and ellipses) 92 | GeomCenter = 3 93 | 94 | #: X Axis Geometry index in Sketch 95 | XAxisIndex = -1 96 | 97 | #: Y Axis Geometry index in Sketch 98 | YAxisIndex = -2 99 | 100 | 101 | # └────────────────────────────────────────────────────────────────────────────┘ 102 | # [SECTION] [Util] Common utilities 103 | # ┌────────────────────────────────────────────────────────────────────────────┐ 104 | 105 | def to_vec(input : Any) -> Vec: 106 | """Convert tuple/list/vector to Vec.""" 107 | if isinstance(input, Vec): 108 | return input 109 | if isinstance(input, (tuple, list)): 110 | if len(input) == 3: 111 | return Vec(*input) 112 | if len(input) == 2: 113 | return Vec(*input, 0) 114 | if len(input) == 1: 115 | return Vec(*input, 0, 0) 116 | if hasattr(input, "X"): 117 | if hasattr(input, "Y"): 118 | if hasattr(input, "Z"): 119 | return Vec(input.X, input.Y, input.Z) 120 | else: 121 | return Vec(input.X, input.Y, 0) 122 | else: 123 | return Vec(input.X, 0, 0) 124 | if isinstance(input, (float, int)): 125 | return Vec(input, 0, 0) 126 | raise RuntimeError(f"Invalid input, {type(input)} is not convertible to Vec") 127 | 128 | 129 | def to_vecs(*input : Any) -> Tuple[Vec, ...]: 130 | """Convert arguments into Vectors. See to_vec.""" 131 | return tuple(to_vec(i) for i in input) 132 | 133 | 134 | def find_obj_origin_geo_feature(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 135 | """Extract Axes or Planes from object's Origin""" 136 | for geo in obj.Origin.OutList: 137 | if geo.Name.startswith(prefix): 138 | return geo 139 | 140 | 141 | def find_obj_origin_axis(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 142 | """Extract Axes from object's Origin""" 143 | return find_obj_origin_geo_feature(obj, f'{prefix.upper()}_Axis') 144 | 145 | 146 | def find_obj_origin_plane(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 147 | """Extract Planes from object's Origin""" 148 | return find_obj_origin_geo_feature(obj, f'{prefix.upper()}_Plane') 149 | 150 | 151 | def recompute(): 152 | """Recompute the whole active document""" 153 | App.ActiveDocument.recompute() 154 | 155 | 156 | def Dx(v: Union[float, Vec]): 157 | """Vector in x direction""" 158 | if isinstance(v, (float, int)): 159 | return Vec(v, 0, 0) 160 | return Vec(v.X, 0, 0) 161 | 162 | 163 | def Dy(v: Union[float, Vec]): 164 | """Vector in y direction""" 165 | if isinstance(v, (float, int)): 166 | return Vec(0, v, 0) 167 | return Vec(0, v.Y, 0) 168 | 169 | 170 | def Dz(v: Union[float, Vec]): 171 | """Vector in z direction""" 172 | if isinstance(v, (float, int)): 173 | return Vec(0, 0, v) 174 | return Vec(0, 0, v.Z) 175 | 176 | #: Decorator 177 | def set_function(target, attribute): 178 | """Decorator: Set function to an attribute of target.""" 179 | def deco(fn): 180 | setattr(target, attribute, fn) 181 | return fn 182 | return deco 183 | 184 | 185 | # └────────────────────────────────────────────────────────────────────────────┘ 186 | # [SECTION] [Expression] Expression Engine Utilities 187 | # ┌────────────────────────────────────────────────────────────────────────────┐ 188 | # 189 | # Example 1 (Eval): 190 | # e1 = Expr(".Length * 3 + <>.Radius") 191 | # result = e1(App.ActiveDocument.Box) 192 | # 193 | # Example 2 (Set): 194 | # e1.set_to(App.ActiveDocument.Pad002, 'Length') 195 | # 196 | # Example 3 (Set in place) 197 | # bind_expr(App.ActiveDocument.Pad002, 'Length', ".Length * 3 + <>.Radius") 198 | # 199 | 200 | class Expr: 201 | """Callable expression (FreeCAD's Expression Engine)""" 202 | 203 | def __init__(self, value: str, context: Any = None): 204 | self.value = value 205 | self.context = context 206 | 207 | def __call__(self, context: Any = None) -> Any: 208 | return (context or self.context).evalExpression(self.value) 209 | 210 | def set_to(self, obj, property: str, auto_recompute=False): 211 | obj.setExpression(property, self.value) 212 | if auto_recompute: 213 | recompute() 214 | 215 | def bind_expr(obj: Any, property: str, expr: Union[str, Expr], auto_recompute: bool = False): 216 | if not isinstance(expr, Expr): 217 | expr = Expr(expr, obj) 218 | expr.set_to(obj, property, auto_recompute) 219 | 220 | 221 | # └────────────────────────────────────────────────────────────────────────────┘ 222 | # [SECTION] [Sketch] Geometry 223 | # ┌────────────────────────────────────────────────────────────────────────────┐ 224 | 225 | class SketchGeom: 226 | """Geometry info inside a sketch""" 227 | def __init__(self, index, obj, name=None): 228 | self.index = index 229 | self.obj = obj 230 | self.name = name 231 | 232 | 233 | # └────────────────────────────────────────────────────────────────────────────┘ 234 | # [SECTION] [Sketch] Solver 235 | # ┌────────────────────────────────────────────────────────────────────────────┐ 236 | 237 | class Solver(Enum): 238 | BFGSSolver = 0 239 | LevenbergMarquardtSolver = 1 240 | DogLegSolver = 2 241 | 242 | 243 | # └────────────────────────────────────────────────────────────────────────────┘ 244 | # [SECTION] [Sketch] Geometric Quantities 245 | # ┌────────────────────────────────────────────────────────────────────────────┐ 246 | 247 | class gq: 248 | 249 | class Base: 250 | value: float 251 | def __init__(self, value: Union[float,str]): 252 | if isinstance(value, str): 253 | self.value = Quantity(str) 254 | else: 255 | self.value = value 256 | 257 | class Radius(Base): 258 | def __init__(self, value: Union[float,str]): 259 | super().__init__(value) 260 | 261 | class Diameter(Base): 262 | def __init__(self, value: Union[float,str]): 263 | super().__init__(value) 264 | 265 | class Length(Base): 266 | def __init__(self, value: Union[float,str]): 267 | super().__init__(value) 268 | 269 | class Angle(Base): 270 | def __init__(self, value: Union[float,str]): 271 | super().__init__(value) 272 | 273 | class DeltaVec(Base): 274 | def __init__(self, value: any): 275 | self.value = to_vec(value) 276 | 277 | # └────────────────────────────────────────────────────────────────────────────┘ 278 | # [SECTION] [Sketch] Sketch 279 | # ┌────────────────────────────────────────────────────────────────────────────┐ 280 | 281 | class XSketch: 282 | """Sketch Wrapper""" 283 | 284 | DEFAULT_SOLVER : Solver = Solver.DogLegSolver 285 | 286 | @staticmethod 287 | def select_default_solver(solver: Solver): 288 | App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher').SetBool('ShowSolverAdvancedWidget', True) 289 | App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher/SolverAdvanced').SetInt('DefaultSolver', solver.value) 290 | 291 | 292 | def __init__(self, name: str = 'XSketch', parent: Any = None, clean: bool = True): 293 | XSketch.select_default_solver(XSketch.DEFAULT_SOLVER) 294 | if parent: 295 | name = f"{parent.Name}_{name}" 296 | self.obj = App.ActiveDocument.getObject(name) 297 | if self.obj: 298 | if clean: 299 | self.obj.deleteAllGeometry() 300 | else: 301 | if parent: 302 | self.obj = parent.newObject("Sketcher::SketchObject", name) 303 | else: 304 | self.obj = App.ActiveDocument.addObject("Sketcher::SketchObject", name) 305 | 306 | self.named_geom = {} 307 | self._ref_mode = False 308 | self.parent = parent 309 | 310 | 311 | @contextmanager 312 | def ref_mode(self, mode : bool = True): 313 | saved = self._ref_mode 314 | if saved == mode: 315 | yield self 316 | else: 317 | self._ref_mode = mode 318 | try: 319 | yield self 320 | finally: 321 | self._ref_mode = saved 322 | 323 | 324 | def set_geom_mode(self): 325 | self._ref_mode = False 326 | 327 | # └────────────────────────────────────────────────────────────────────────────┘ 328 | # [SECTION] [Sketch/Geometry] 329 | # ┌────────────────────────────────────────────────────────────────────────────┐ 330 | 331 | def g_line(self, start, end): 332 | """Draw a line segment from start to end""" 333 | p0, p1 = to_vec(start), to_vec(end) 334 | line = Part.LineSegment(p0, p1) 335 | index = self.obj.addGeometry(line, self._ref_mode) 336 | return SketchGeom(index, line) 337 | 338 | 339 | def g_regular_polygon(self, p1, p2, edges): 340 | """Draw a regular polygon (center, vertex, edges).""" 341 | RegularPolygon.makeRegularPolygon(self.obj, edges, to_vec(p1), to_vec(p2), self._ref_mode) 342 | index = len(self.obj.Geometry) - 1 343 | return SketchGeom(index, self.obj.Geometry[index]) 344 | 345 | 346 | def g_bspline(self, poles, mults=None, knots=None, periodic=False, degree=3, weights=None, check_rational=False): 347 | """Draw a bspline""" 348 | vec_poles = [to_vec(p) for p in poles] 349 | bspline = Part.BSplineCurve(vec_poles, mults, knots, periodic, degree, weights, check_rational) 350 | index = self.obj.addGeometry(bspline, self._ref_mode) 351 | return SketchGeom(index, bspline) 352 | 353 | 354 | def g_circle_center_radius(self, cnt, rad): 355 | c = to_vec(cnt) 356 | circle = Part.Circle(c, Vec(0,0,1), rad) 357 | index = self.obj.addGeometry(circle, self._ref_mode) 358 | return SketchGeom(index, circle) 359 | 360 | 361 | def g_circle_3points(self, p1, p2, p3): 362 | v1, v2, v3 = to_vecs(p1, p2, p3) 363 | circle = Part.Circle(v1, v2, v3) 364 | index = self.obj.addGeometry(circle, self._ref_mode) 365 | return SketchGeom(index, circle) 366 | 367 | 368 | def g_arc_3points(self, p1, p2, p3): 369 | v1, v2, v3 = to_vecs(p1, p2, p3) 370 | arc = Part.Arc(v1, v2, v3) 371 | index = self.obj.addGeometry(arc, self._ref_mode) 372 | return SketchGeom(index, arc) 373 | 374 | 375 | def g_arc_center_radius(self, cnt, rad, start=0, end=math.radians(180)): 376 | c = to_vec(cnt) 377 | circle = Part.Circle(c, Vec(0,0,1), rad) 378 | arc = Part.ArcOfCircle(circle, start, end) 379 | index = self.obj.addGeometry(arc, self._ref_mode) 380 | return SketchGeom(index, arc) 381 | 382 | 383 | def g_point(self, p): 384 | pnt = Part.Point(p) 385 | index = self.obj.addGeometry(pnt, self._ref_mode) 386 | return SketchGeom(index, pnt) 387 | 388 | # └────────────────────────────────────────────────────────────────────────────┘ 389 | # [SECTION] [Sketch/Constraints] 390 | # ┌────────────────────────────────────────────────────────────────────────────┐ 391 | 392 | def rename_constraint(self, constraint, name): 393 | if name: 394 | self.obj.renameConstraint(constraint, name) 395 | 396 | 397 | def c_coincident(self, g1, g1c, g2, g2c, name=None): 398 | c = self.obj.addConstraint(Sketcher.Constraint("Coincident", g1, g1c, g2, g2c)) 399 | self.rename_constraint(c, name) 400 | return c 401 | 402 | 403 | def c_vertical(self, index, name=None): 404 | c = self.obj.addConstraint(Sketcher.Constraint("Vertical", index)) 405 | self.rename_constraint(c, name) 406 | return c 407 | 408 | 409 | def c_horizontal(self, index, name=None): 410 | c = self.obj.addConstraint(Sketcher.Constraint("Horizontal", index)) 411 | self.rename_constraint(c, name) 412 | return c 413 | 414 | 415 | def c_coincident_end_start(self, g1, g2, name=None): 416 | g_prev = self.obj.Geometry[g1] 417 | g_current = self.obj.Geometry[g2] 418 | c_end = GeomEnd if hasattr(g_prev, "EndPoint") else GeomCenter 419 | c_start = GeomStart if hasattr(g_current, "StartPoint") else GeomCenter 420 | c = self.c_coincident(g1, c_end, g2, c_start) 421 | self.rename_constraint(c, name) 422 | return c 423 | 424 | 425 | def c_x_angle(self, index: int, angle: Union[float, Expr], name=None): 426 | return self.c_angle(XAxisIndex, GeomStart, index, GeomStart, angle, name=name) 427 | 428 | 429 | def c_y_angle(self, index, angle: Union[float, Expr], name=None): 430 | return self.c_angle(YAxisIndex, GeomStart, index, GeomStart, angle, name=name) 431 | 432 | 433 | def c_angle(self, g1, g1c, g2, g2c, angle: Union[float, Expr], name=None): 434 | if isinstance(angle, Expr): 435 | value = angle(self.obj) 436 | c = self.obj.addConstraint(Sketcher.Constraint("Angle", g1, g1c, g2, g2c, value)) 437 | angle.set_to(self.obj, f'.Constraints[{c}]') 438 | else: 439 | c = self.obj.addConstraint(Sketcher.Constraint("Angle", g1, g1c, g2, g2c, angle)) 440 | self.rename_constraint(c, name) 441 | return c 442 | 443 | 444 | def c_length(self, index, length: Union[float, Expr], name=None): 445 | if isinstance(length, Expr): 446 | value = length(self.obj) 447 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", index, value)) 448 | length.set_to(self.obj, f'.Constraints[{c}]') 449 | else: 450 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", index, length)) 451 | self.rename_constraint(c, name) 452 | return c 453 | 454 | 455 | def c_distance(self, g1, g1p, g2, g2p, dist: Union[float, Expr], name=None): 456 | if isinstance(dist, Expr): 457 | value = dist(self.obj) 458 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", g1, g1p, g2, g2p, value)) 459 | dist.set_to(self.obj, f'.Constraints[{c}]') 460 | else: 461 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", g1, g1p, g2, g2p, dist)) 462 | self.rename_constraint(c, name) 463 | return c 464 | 465 | 466 | def c_perpendicular(self, g1, g2, name=None): 467 | c = self.obj.addConstraint(Sketcher.Constraint("Perpendicular", g1, g2)) 468 | self.rename_constraint(c, name) 469 | return c 470 | 471 | 472 | def c_parallel(self, g1, g2, name=None): 473 | c = self.obj.addConstraint(Sketcher.Constraint("Parallel", g1, g2)) 474 | self.rename_constraint(c, name) 475 | return c 476 | 477 | 478 | def _c_xy(self, g, distance, type, anchor=GeomStart, name=None): 479 | if isinstance(distance, Expr): 480 | value = distance(self.obj) 481 | c = self.obj.addConstraint(Sketcher.Constraint(f"Distance{type}", g, anchor, value)) 482 | distance.set_to(self.obj, f'.Constraints[{c}]') 483 | else: 484 | c = self.obj.addConstraint(Sketcher.Constraint(f"Distance{type}", g, anchor, distance)) 485 | self.rename_constraint(c, name) 486 | return c 487 | 488 | 489 | def c_x(self, g, distance, anchor=GeomStart, name=None): 490 | return self._c_xy(g, distance, 'X', anchor, name) 491 | 492 | 493 | def c_y(self, g, distance, anchor=GeomStart, name=None): 494 | return self._c_xy(g, distance, 'Y', anchor, name) 495 | 496 | 497 | def c_xy(self, g, x, y, anchor=GeomStart, name=None): 498 | c1 = self.c_x(g, x, anchor=anchor, name=None if name is None else f'{name}_x') 499 | c2 = self.c_y(g, y, anchor=anchor, name=None if name is None else f'{name}_y') 500 | return c1, c2 501 | 502 | 503 | def c_point_on_object(self, pnt, obj, name=None): 504 | c = self.obj.addConstraint(Sketcher.Constraint("PointOnObject", pnt, GeomStart, obj)) 505 | self.rename_constraint(c, name) 506 | return c 507 | 508 | 509 | def c_equal(self, g1, g2, name=None): 510 | c = self.obj.addConstraint(Sketcher.Constraint("Equal", g1, g2)) 511 | self.rename_constraint(c, name) 512 | return c 513 | 514 | 515 | def c_diameter(self, g, diameter, name=None): 516 | if isinstance(diameter, Expr): 517 | value = diameter(self.obj) 518 | c = self.obj.addConstraint(Sketcher.Constraint("Diameter", g, value)) 519 | diameter.set_to(self.obj, f'.Constraints[{c}]') 520 | else: 521 | c = self.obj.addConstraint(Sketcher.Constraint("Diameter", g, diameter)) 522 | self.rename_constraint(c, name) 523 | return c 524 | 525 | 526 | def c_radius(self, g, radius, name=None): 527 | if isinstance(radius, Expr): 528 | value = radius(self.obj) 529 | c = self.obj.addConstraint(Sketcher.Constraint("Radius", g, value)) 530 | radius.set_to(self.obj, f'.Constraints[{c}]') 531 | else: 532 | c = self.obj.addConstraint(Sketcher.Constraint("Radius", g, radius)) 533 | self.rename_constraint(c, name) 534 | return c 535 | 536 | 537 | def c_auto_coincident(self, name=None): 538 | g_len = len(self.obj.Geometry) 539 | if g_len > 1: 540 | return self.c_coincident_end_start(g_len-2, g_len-1, name=name) 541 | 542 | 543 | def c_bspline_control_point(self, g, g_c, bspl, bspl_c, weight=1.0): 544 | with self.ref_mode(): 545 | geom = self.obj.Geometry[g] 546 | pnt = None 547 | if g_c == GeomStart: 548 | if hasattr(geom, "StartPoint"): 549 | pnt = geom.StartPoint 550 | elif hasattr(geom, "X"): 551 | pnt = Pnt(geom.X, geom.Y, geom.Z) 552 | elif g_c == GeomEnd: 553 | if hasattr(geom, "EndPoint"): 554 | pnt = geom.EndPoint 555 | elif hasattr(geom, "X"): 556 | pnt = Pnt(geom.X, geom.Y, geom.Z) 557 | elif g_c == GeomCenter: 558 | if hasattr(geom, "Center"): 559 | pnt = geom.Center 560 | elif hasattr(geom, "X"): 561 | pnt = Pnt(geom.X, geom.Y, geom.Z) 562 | if pnt is None: 563 | raise RuntimeError(f"Unsupported constraint {g}.{g_c}") 564 | 565 | cpc = self.g_circle_center_radius(pnt, 10) 566 | self.obj.addConstraint(Sketcher.Constraint('Weight', cpc.index, weight)) 567 | self.obj.addConstraint(Sketcher.Constraint('Coincident', cpc.index, GeomCenter, g, g_c)) 568 | self.obj.addConstraint(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint', cpc.index, GeomCenter, bspl, bspl_c)) 569 | 570 | # └────────────────────────────────────────────────────────────────────────────┘ 571 | # [SECTION] [Sketch/Lookup] 572 | # ┌────────────────────────────────────────────────────────────────────────────┐ 573 | 574 | def g_by_name(self, name): 575 | index = self.named_geom.get(name, None) 576 | if index: 577 | return SketchGeom(index, self.obj.Geometry[index], name) 578 | 579 | def g_by_index(self, index): 580 | return SketchGeom(index, self.obj.Geometry[index]) 581 | 582 | def set_name(self, name, geom): 583 | if name in self.named_geom: 584 | raise RuntimeError("Duplicated name") 585 | if isinstance(geom, SketchGeom): 586 | self.named_geom[name] = geom.index 587 | else: 588 | self.named_geom[name] = geom 589 | 590 | # └────────────────────────────────────────────────────────────────────────────┘ 591 | # [SECTION] [Sketch/Builders] 592 | # ┌────────────────────────────────────────────────────────────────────────────┐ 593 | 594 | def create_group(self, auto_coincident=True): 595 | return XSketchGroup(self, auto_coincident) 596 | 597 | # └────────────────────────────────────────────────────────────────────────────┘ 598 | # [SECTION] [Sketch/Placement] 599 | # ┌────────────────────────────────────────────────────────────────────────────┐ 600 | 601 | def rotate(self, angle): 602 | self.obj.AttachmentOffset = App.Placement( 603 | self.obj.AttachmentOffset.Base, 604 | App.Rotation(self.obj.AttachmentOffset.Rotation.Axis, angle) 605 | ) 606 | 607 | def move(self, pos): 608 | self.obj.AttachmentOffset = App.Placement( 609 | to_vec(pos), 610 | self.obj.AttachmentOffset.Rotation 611 | ) 612 | 613 | def attach(self, pos, axis, angle): 614 | self.obj.AttachmentOffset = App.Placement( 615 | to_vec(pos), 616 | App.Rotation(to_vec(axis), angle) 617 | ) 618 | 619 | # └────────────────────────────────────────────────────────────────────────────┘ 620 | # [SECTION] [Sketch/PartDesign] 621 | # ┌────────────────────────────────────────────────────────────────────────────┐ 622 | 623 | def pad(self, value, name='Pad', direction=None): 624 | name = f"{self.obj.Name}_{name}" 625 | if self.parent: 626 | feature = self.parent.getObject(name) 627 | if not feature: 628 | feature = self.parent.newObject('PartDesign::Pad', name) 629 | else: 630 | if feature.TypeId != 'PartDesign::Pad': 631 | raise RuntimeError(f'The named object "{name}" already exists but it is not a PartDesign::Pad') 632 | feature.Profile = self.obj 633 | feature.Length = value 634 | if direction: 635 | feature.UseCustomVector = True 636 | feature.Direction = direction 637 | return feature 638 | 639 | 640 | # └────────────────────────────────────────────────────────────────────────────┘ 641 | # [SECTION] [Sketch] Group 642 | # ┌────────────────────────────────────────────────────────────────────────────┐ 643 | 644 | class XSketchGroup: 645 | 646 | def __init__(self, sketch: XSketch, auto_coincident: bool=True): 647 | self.sketch = sketch 648 | self.begin = None 649 | self.auto_coincident = auto_coincident 650 | self.pos = Pnt(0,0,0) 651 | 652 | def _concat(self, pnt, g, name): 653 | if self.begin is not None and g.index > 0 and self.auto_coincident: 654 | self.sketch.c_auto_coincident() 655 | self.pos = pnt 656 | if self.begin is None: 657 | self.begin = g.index 658 | if name: 659 | self.sketch.set_name(name, g) 660 | 661 | def line_to(self, pnt_, name=None): 662 | pnt = to_vec(pnt_) 663 | g = self.sketch.g_line(self.pos, pnt) 664 | self._concat(pnt, g, name) 665 | return g 666 | 667 | def line(self, dx=0, dy=0, name=None): 668 | return self.line_to(self.pos + Vec(dx, dy, 0), name=name) 669 | 670 | def close(self, keep=False): 671 | if self.begin is not None: 672 | begin = self.sketch.g_by_index(self.begin) 673 | g = self.line_to(begin.obj.StartPoint) 674 | if self.auto_coincident: 675 | self.sketch.c_coincident_end_start(g.index, self.begin) 676 | if not keep: 677 | self.begin = None 678 | return g 679 | 680 | def by_name(self, name): 681 | return self.sketch.g_by_name(name) 682 | 683 | def move_to(self, pnt): 684 | self.pos = to_vec(pnt) 685 | self.begin = None 686 | 687 | def move(self, dx=0, dy=0): 688 | self.pos = self.pos + to_vec((dx, dy)) 689 | self.begin = None 690 | 691 | def bspline_to(self, *pnts, name=None): 692 | pnt = to_vec(pnts[-1]) 693 | g = self.sketch.g_bspline([self.pos, *pnts]) 694 | self._concat(pnt, g, name) 695 | return g 696 | 697 | def bspline(self, *pnts, name=None): 698 | start = self.pos 699 | apnts = [] 700 | for pnt in pnts: 701 | start = start + pnt 702 | apnts.append(start) 703 | pnt = to_vec(apnts[-1]) 704 | g = self.sketch.g_bspline([self.pos, *apnts]) 705 | self._concat(pnt, g, name) 706 | return g 707 | 708 | def circle(self, radius, name=None): 709 | g = self.sketch.g_circle_center_radius(self.pos, radius) 710 | self._concat(self.pos, g, name) 711 | return g 712 | 713 | def point(self, name=None): 714 | g = self.sketch.g_point(self.pos) 715 | self.pos = to_vec(g.obj) 716 | if name: 717 | self.sketch.set_name(name, g) 718 | return g 719 | 720 | def circle_3points(self, p2, p3, name=None): 721 | g = self.sketch.g_circle_3points(self.pos, p2, p3) 722 | self._concat(to_vec(p3), g, name) 723 | return g 724 | 725 | def rect_to(self, pnt): 726 | orig = self.pos 727 | self.move_to(orig) 728 | g = self.line_to((orig[0], pnt[1])) 729 | self.sketch.c_vertical(g.index) 730 | g = self.line_to(pnt) 731 | self.sketch.c_horizontal(g.index) 732 | g = self.line_to((pnt[0], orig[1])) 733 | self.sketch.c_vertical(g.index) 734 | g = self.close() 735 | self.sketch.c_horizontal(g.index) 736 | return g 737 | 738 | def rect(self, w, h, angle=Quantity('0.0 deg')): 739 | if w <= 0: 740 | raise(RuntimeError(f'Invalid w={w} too small')) 741 | if h <= 0: 742 | raise(RuntimeError(f'Invalid h={h} too small')) 743 | orig = self.pos 744 | self.move_to(orig) 745 | g1 = self.line(dx=w) 746 | self.sketch.c_length(g1.index, w) 747 | g2 = self.line(dy=h) 748 | self.sketch.c_length(g2.index, h) 749 | self.sketch.c_angle(g1.index, GeomEnd, g2.index, GeomStart, Quantity('270 deg')) 750 | g3 = self.line(dx=-w) 751 | self.sketch.c_length(g3.index, w) 752 | self.sketch.c_angle(g2.index, GeomEnd, g3.index, GeomStart, Quantity('270 deg')) 753 | g4 = self.close() 754 | self.sketch.c_x_angle(g1.index, angle) 755 | self.sketch.c_xy(g1.index, orig[0], orig[1]) 756 | return g4 757 | 758 | def regular_polygon(self, param: any, edges: int, angle=None, constrain_pos: Optional[bool]=None, constrain_size: bool=True): 759 | orig = self.pos 760 | prev_index = len(self.sketch.obj.Geometry) - 1 761 | if isinstance(param, gq.Length): 762 | r = param.value / (2 * math.sin(math.pi/edges)) 763 | p2 = orig + Vec(0, r, 0) 764 | elif isinstance(param, gq.Radius): 765 | r = param.value 766 | p2 = orig + Vec(0, r, 0) 767 | elif isinstance(param, gq.Diameter): 768 | r = param.value/2.0 769 | p2 = orig + Vec(0, r, 0) 770 | elif isinstance(param, gq.DeltaVec): 771 | r = param.value.Length 772 | p2 = orig + param.value 773 | elif isinstance(param, (float,int)): # Implies Diameter 774 | r = param/2.0 775 | p2 = orig + Vec(0, r, 0) 776 | else: # Absolute point 777 | p2 = to_vec(param) 778 | r = (orig - p2).Length 779 | g = self.sketch.g_regular_polygon(orig, p2, edges) 780 | 781 | if constrain_pos is True: 782 | self.sketch.c_xy(g.index, orig[0], orig[1], anchor=GeomCenter) 783 | elif constrain_pos is None and self.auto_coincident and prev_index > -1 and self.begin: 784 | self.sketch.c_coincident_end_start(prev_index, g.index) 785 | 786 | if constrain_size: 787 | if isinstance(param, gq.Length): 788 | self.sketch.c_length(g.index-1, param.value) 789 | elif isinstance(param, gq.Radius): 790 | self.sketch.c_radius(g.index, param.value) 791 | elif isinstance(param, gq.Diameter): 792 | self.sketch.c_diameter(g.index, param.value) 793 | elif isinstance(param, gq.DeltaVec): 794 | self.sketch.c_radius(g.index, r) 795 | else: 796 | length = r * (2 * math.sin(math.pi/edges)) 797 | self.sketch.c_length(g.index-1, length) 798 | 799 | if angle is not None: 800 | with self.sketch.ref_mode(): 801 | seg = self.sketch.g_line(orig, p2) 802 | self.sketch.c_coincident(g.index, GeomCenter, seg.index, GeomStart) 803 | self.sketch.c_coincident(prev_index+1, GeomStart, seg.index, GeomEnd) 804 | self.sketch.c_y_angle(seg.index, angle) 805 | 806 | return g 807 | 808 | 809 | def line_with_length(self, dx=0, dy=0, ref=False, constraint_name=None): 810 | with self.sketch.ref_mode(ref): 811 | if dx == 0 and dy != 0: 812 | length = abs(dy) 813 | elif dx != 0 and dy == 0: 814 | length = abs(dx) 815 | elif dx != 0 and dy != 0: 816 | length = math.sqrt(dx*dx + dy*dy) 817 | else: 818 | raise(RuntimeError(f"Invalid (dx, dy) = ({dx}, {dy})")) 819 | seg = self.line(dx, dy) 820 | self.sketch.c_length(seg.index, length, name=constraint_name) 821 | return seg 822 | 823 | 824 | def rect_rounded(self, w, h, r, angle=Quantity('0.0 deg')): 825 | """ 826 | r: int | float => for all corners 827 | (rw, rh) => width and height radious 828 | (tl, tr, br, bl) => custom radius for each corner 829 | """ 830 | if w <= 0: 831 | raise(RuntimeError(f'Invalid w={w} too small')) 832 | if h <= 0: 833 | raise(RuntimeError(f'Invalid h={h} too small')) 834 | 835 | BL, BR, TR, TL = 0, 1, 2, 3 # Corners 836 | RW, RH = 0, 1 # Radiuses 837 | 838 | # transform r into ( (blw, blh), (brw, brh), (trw, trh), (tlw, tlh) ) 839 | if isinstance(r, (float, int)): 840 | r = ((r,r),)*4 841 | elif len(r) == 1: 842 | r = ((r,r),)*4 843 | elif len(r) == 2: 844 | r = ((r[0],r[1]),)*4 845 | elif len(r) == 4: 846 | r = ((r[0],r[0]),(r[1],r[1]),(r[2],r[2]),(r[3],r[3])) 847 | else: 848 | raise(RuntimeError(f'Invalid r={r}')) 849 | 850 | if any((x[RW] <= 0 or x[RH] <= 0 for x in r)): 851 | raise(RuntimeError(f'Invalid r={r} too small values')) 852 | 853 | if ( (r[BL][RW] + r[BR][RW] > w) 854 | or (r[TR][RW] + r[TL][RW] > w) 855 | or (r[BL][RH] + r[TL][RH] > h) 856 | or (r[BR][RH] + r[TR][RH] > h)): 857 | raise(RuntimeError(f'Invalid r={r} too large values')) 858 | 859 | orig = self.pos 860 | 861 | self.move_to(orig) 862 | 863 | angle180 = Quantity('180 deg') 864 | angle270 = Quantity('270 deg') 865 | 866 | # Bottom 867 | bl = self.line_with_length(dx=r[BL][RW], ref=True) 868 | bottom = self.line_with_length(dx=w - r[BL][RW] - r[BR][RW]) 869 | br = self.line_with_length(dx=r[BR][RW], ref=True) 870 | 871 | self.sketch.c_angle(bl.index, GeomEnd, bottom.index, GeomStart, angle180) 872 | self.sketch.c_angle(bottom.index, GeomEnd, br.index, GeomStart, angle180) 873 | 874 | # Right 875 | rb = self.line_with_length(dy=r[BR][RH], ref=True) 876 | right = self.line_with_length(dy=h - r[BR][RH] - r[TR][RH]) 877 | rt = self.line_with_length(dy=-r[TR][RH], ref=True) 878 | 879 | self.sketch.c_angle(br.index, GeomEnd, rb.index, GeomStart, angle270) 880 | self.sketch.c_angle(rb.index, GeomEnd, right.index, GeomStart, angle180) 881 | self.sketch.c_angle(right.index, GeomEnd, rt.index, GeomStart, angle180) 882 | 883 | # Top 884 | tr = self.line_with_length(dx=-r[TR][RW], ref=True) 885 | top = self.line_with_length(dx=-w + r[TR][RW] + r[TL][RW]) 886 | tl = self.line_with_length(dx=-r[TL][RW], ref=True) 887 | 888 | self.sketch.c_angle(rt.index, GeomEnd, tr.index, GeomStart, angle270) 889 | self.sketch.c_angle(tr.index, GeomEnd, top.index, GeomStart, angle180) 890 | self.sketch.c_angle(top.index, GeomEnd, tl.index, GeomStart, angle180) 891 | 892 | # Left 893 | lt = self.line_with_length(dy=-r[TL][RH], ref=True) 894 | left = self.line_with_length(dy=-h + r[BL][RH] + r[TL][RH]) 895 | lb = self.line_with_length(dy=r[BL][RH], ref=True) 896 | 897 | self.sketch.c_angle(tl.index, GeomEnd, lt.index, GeomStart, angle270) 898 | self.sketch.c_angle(lt.index, GeomEnd, left.index, GeomStart, angle180) 899 | self.sketch.c_angle(left.index, GeomEnd, lb.index, GeomStart, angle180) 900 | 901 | # Corner: BL 902 | self.move_to(left.obj.EndPoint) 903 | blc = self.bspline_to(lb.obj.EndPoint, bottom.obj.StartPoint) 904 | self.sketch.c_bspline_control_point(left.index, GeomEnd, blc.index, 0) 905 | self.sketch.c_bspline_control_point(lb.index, GeomEnd, blc.index, 1) 906 | self.sketch.c_bspline_control_point(bottom.index, GeomStart, blc.index, 2) 907 | 908 | # Corner: BR 909 | self.move_to(bottom.obj.EndPoint) 910 | blc = self.bspline_to(br.obj.EndPoint, rb.obj.StartPoint) 911 | self.sketch.c_bspline_control_point(bottom.index, GeomEnd, blc.index, 0) 912 | self.sketch.c_bspline_control_point(br.index, GeomEnd, blc.index, 1) 913 | self.sketch.c_bspline_control_point(right.index, GeomStart, blc.index, 2) 914 | 915 | # Corner: TR 916 | self.move_to(right.obj.EndPoint) 917 | blc = self.bspline_to(rt.obj.EndPoint, top.obj.StartPoint) 918 | self.sketch.c_bspline_control_point(right.index, GeomEnd, blc.index, 0) 919 | self.sketch.c_bspline_control_point(rt.index, GeomEnd, blc.index, 1) 920 | self.sketch.c_bspline_control_point(top.index, GeomStart, blc.index, 2) 921 | 922 | # Corner: TL 923 | self.move_to(top.obj.EndPoint) 924 | blc = self.bspline_to(tl.obj.EndPoint, left.obj.StartPoint) 925 | self.sketch.c_bspline_control_point(top.index, GeomEnd, blc.index, 0) 926 | self.sketch.c_bspline_control_point(tl.index, GeomEnd, blc.index, 1) 927 | self.sketch.c_bspline_control_point(left.index, GeomStart, blc.index, 2) 928 | 929 | # Pos 930 | self.sketch.c_xy(bl.index, orig[0], orig[1]) 931 | 932 | # Angle 933 | self.sketch.c_x_angle(bottom.index, angle) 934 | 935 | return blc 936 | 937 | 938 | # └────────────────────────────────────────────────────────────────────────────┘ 939 | # [SECTION] [PartDesign] Body 940 | # ┌────────────────────────────────────────────────────────────────────────────┐ 941 | 942 | 943 | class XBody: 944 | """PartDesign Body builder""" 945 | 946 | def __init__(self, name='XBody'): 947 | """Create or reuse a PartDesign Body""" 948 | self.obj = App.ActiveDocument.getObject(name) 949 | if not self.obj: 950 | self.obj = App.ActiveDocument.addObject('PartDesign::Body', name) 951 | else: 952 | if self.obj.TypeId != 'PartDesign::Body': 953 | raise RuntimeError(f'The named object "{name}" already exists but it is not a PartDesign::Body') 954 | 955 | 956 | @property 957 | def name(self): 958 | return self.obj.Name 959 | 960 | 961 | def sketch(self, name:str='XSketch', plane:str='XY', reversed:bool=False, pos:Vec=Vec(0,0,0), rot:Rotation=Rotation(0,0,0)): 962 | """Sketch builder.""" 963 | support = find_obj_origin_plane(self.obj, plane) 964 | return self.sketch_on([(support, '')], name=name, reversed=reversed, pos=pos, rot=rot) 965 | 966 | 967 | def sketch_on(self, support, mode='FlatFace', parameter=0.0, name:str='XSketch', reversed:bool=False, pos:Vec=Vec(0,0,0), rot:Rotation=Rotation(0,0,0)): 968 | """Sketch builder.""" 969 | xsketch = XSketch(name, parent=self.obj) 970 | sketch = xsketch.obj 971 | sketch.AttachmentOffset = App.Placement(pos, rot) 972 | sketch.MapReversed = reversed 973 | sketch.Support = support 974 | sketch.MapPathParameter = parameter 975 | sketch.MapMode = mode 976 | return xsketch 977 | 978 | 979 | def fillet(self, edges: list, name='Fillet'): 980 | name = f"{self.name}_{name}" 981 | fillet = App.ActiveDocument.getObject(name) 982 | if not fillet: 983 | fillet = App.ActiveDocument.addObject('Part::Fillet', name) 984 | fillet.Base = self.obj 985 | fillet.Edges = [*edges] 986 | Gui.ActiveDocument.getObject(self.name).Visibility = False 987 | 988 | 989 | # └────────────────────────────────────────────────────────────────────────────┘ 990 | # [SECTION] [FeaturePython] Data Object 991 | # ┌────────────────────────────────────────────────────────────────────────────┐ 992 | 993 | class DataObject: 994 | """Document Object with properties""" 995 | 996 | def __init__(self, name="Data"): 997 | obj = App.ActiveDocument.getObject(name) 998 | if not obj: 999 | obj = App.ActiveDocument.addObject('App::FeaturePython', name) 1000 | else: 1001 | if obj.TypeId != 'App::FeaturePython': 1002 | raise RuntimeError(f'The named object "{name}" already exists but it is not of type App::FeaturePython') 1003 | super().__setattr__('obj', obj) 1004 | 1005 | def add_property(self, property_type, name, section="Properties", docs="", mode=0): 1006 | try: 1007 | self.obj.addProperty(property_type, name, section, docs, mode) 1008 | except: 1009 | pass # Ignore if already exists 1010 | 1011 | def __setattr__(self, __name: str, __value: Any) -> None: 1012 | if hasattr(self, __name): 1013 | super().__setattr__(__name, __value) 1014 | else: 1015 | if isinstance(__value, Expr): 1016 | self.obj.__setattr__(__name, __value(self.obj)) 1017 | __value.set_to(self.obj, __name) 1018 | else: 1019 | self.obj.setExpression(__name, None) 1020 | self.obj.__setattr__(__name, __value) 1021 | 1022 | def __getattr__(self, __name: str) -> Any: 1023 | return self.obj.__getattr(__name) 1024 | 1025 | # └────────────────────────────────────────────────────────────────────────────┘ 1026 | # [SECTION] [GUI] Imports 1027 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1028 | 1029 | from PySide import QtCore, QtGui 1030 | 1031 | # └────────────────────────────────────────────────────────────────────────────┘ 1032 | # [SECTION] [GUI] Aliases 1033 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1034 | 1035 | Icon = QtGui.QIcon 1036 | Image = QtGui.QPixmap 1037 | 1038 | # └────────────────────────────────────────────────────────────────────────────┘ 1039 | # [SECTION] [GUI] Globals 1040 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1041 | 1042 | ThreadLocalGuiVars = threading.local() # Store GUI State per thread 1043 | 1044 | 1045 | # └────────────────────────────────────────────────────────────────────────────┘ 1046 | # [SECTION] [GUI] Utils 1047 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1048 | 1049 | def set_qt_attrs(qt_object, **kwargs): 1050 | """Call setters on QT objects by argument names.""" 1051 | for name, value in kwargs.items(): 1052 | if value is not None: 1053 | setter = getattr(qt_object, f'set{name[0].upper()}{name[1:]}', None) 1054 | if setter: 1055 | if isinstance(value, tuple): 1056 | setter(*value) 1057 | else: 1058 | setter(value) 1059 | 1060 | 1061 | def setup_layout(layout, add=True, **kwargs): 1062 | """Setup layouts adding wrapper widget if required.""" 1063 | set_qt_attrs(layout, **kwargs) 1064 | parent = build_context().current() 1065 | if parent.layout() is not None or add is False: 1066 | w = QtGui.QWidget() 1067 | w.setLayout(layout) 1068 | if add: 1069 | parent.layout().addWidget(w) 1070 | with build_context().stack(w): 1071 | yield w 1072 | else: 1073 | parent.setLayout(layout) 1074 | yield parent 1075 | 1076 | 1077 | def place_widget(ed, label=None, stretch=0, alignment=QtCore.Qt.Alignment()): 1078 | """Place widget in layout.""" 1079 | layout = build_context().current().layout() 1080 | if layout is None: 1081 | layout = QtGui.QVBoxLayout() 1082 | build_context().current().setLayout(layout) 1083 | if label is None: 1084 | build_context().current().layout().addWidget(ed, stretch, alignment) 1085 | else: 1086 | w = QtGui.QWidget() 1087 | parent = QtGui.QHBoxLayout() 1088 | parent.addWidget(QtGui.QLabel(label)) 1089 | parent.addWidget(ed) 1090 | w.setLayout(parent) 1091 | build_context().current().layout().addWidget(w, stretch, alignment) 1092 | 1093 | 1094 | class PySignal: 1095 | """Imitate Qt Signals for non QObject objects""" 1096 | 1097 | def __init__(self): 1098 | self._listeners = [] 1099 | 1100 | def connect(self, listener): 1101 | self._listeners.append(listener) 1102 | 1103 | def trigger(self, *args, **kwargs): 1104 | for listener in self._listeners: 1105 | listener(*args, **kwargs) 1106 | 1107 | 1108 | #: Decorator 1109 | def on_event(target, event=None): 1110 | """Decorator: Event binder""" 1111 | if event is None: 1112 | def deco(fn): 1113 | target.connect(fn) 1114 | return fn 1115 | else: 1116 | def deco(fn): 1117 | getattr(target, event).connect(fn) 1118 | return fn 1119 | return deco 1120 | 1121 | 1122 | # └────────────────────────────────────────────────────────────────────────────┘ 1123 | # [SECTION] [GUI] Selection 1124 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1125 | 1126 | class SelectedObject: 1127 | """Store Selection information""" 1128 | 1129 | def __init__(self, doc, obj, sub=None, pnt=None): 1130 | self.doc = doc 1131 | self.obj = obj 1132 | self.sub = sub 1133 | self.pnt = pnt 1134 | 1135 | def __iter__(self): 1136 | yield App.getDocument(self.doc).getObject(self.obj) 1137 | yield self.sub 1138 | yield self.pnt 1139 | 1140 | def __repr__(self) -> str: 1141 | return f"{self.doc}#{self.obj}.{self.sub}" 1142 | 1143 | def __hash__(self) -> int: 1144 | return hash((self.doc, self.obj, self.sub)) 1145 | 1146 | def __eq__(self, __o: object) -> bool: 1147 | return hash(self) == hash(__o) 1148 | 1149 | def __ne__(self, __o: object) -> bool: 1150 | return not self.__eq__(__o) 1151 | 1152 | 1153 | def register_select_observer(owner: QtGui.QWidget, observer): 1154 | """Add observer with auto remove on owner destroyed""" 1155 | Gui.Selection.addObserver(observer) 1156 | def destroyed(_): 1157 | Gui.Selection.removeObserver(observer) 1158 | owner.destroyed.connect(destroyed) 1159 | 1160 | 1161 | @contextmanager 1162 | def selection(*names, clean=True): 1163 | sel = Gui.Selection 1164 | try: 1165 | doc = App.ActiveDocument.Name 1166 | if len(names) == 0: 1167 | yield sel.getSelection(doc) 1168 | else: 1169 | sel.clearSelection() 1170 | for name in names: 1171 | if isinstance(name, (tuple, list)): 1172 | sel.addSelection(doc, *name) 1173 | elif isinstance(name, SelectedObject): 1174 | sel.addSelection(name.doc, name.obj, name.sub) 1175 | else: 1176 | sel.addSelection(doc, name) 1177 | yield sel.getSelection(doc) 1178 | finally: 1179 | if clean: 1180 | sel.clearSelection() 1181 | 1182 | 1183 | # └────────────────────────────────────────────────────────────────────────────┘ 1184 | # [SECTION] [GUI] Build Context 1185 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1186 | 1187 | def build_context(): 1188 | bc = getattr(ThreadLocalGuiVars, 'BuildContext', None) 1189 | if bc is None: 1190 | ThreadLocalGuiVars.BuildContext = _BuildContext() 1191 | return ThreadLocalGuiVars.BuildContext 1192 | else: 1193 | return bc 1194 | 1195 | class _BuildContext: 1196 | def __init__(self): 1197 | self._stack = [] 1198 | 1199 | def push(self, widget): 1200 | self._stack.append(widget) 1201 | 1202 | def pop(self): 1203 | self._stack.pop() 1204 | 1205 | @contextmanager 1206 | def stack(self, widget): 1207 | self.push(widget) 1208 | try: 1209 | yield widget 1210 | finally: 1211 | self.pop() 1212 | 1213 | @contextmanager 1214 | def parent(self): 1215 | if len(self._stack) > 1: 1216 | current = self._stack[-1] 1217 | self._stack.pop() 1218 | parent = self._stack[-1] 1219 | try: 1220 | yield parent 1221 | finally: 1222 | self._stack.append(current) 1223 | 1224 | def current(self): 1225 | return self._stack[-1] 1226 | 1227 | def dump(self): 1228 | print(f"BuildContext: {self._stack}") 1229 | 1230 | @contextmanager 1231 | def Parent(): 1232 | """Put parent on top of BuildContext""" 1233 | with build_context().parent() as p: 1234 | yield p 1235 | 1236 | 1237 | # └────────────────────────────────────────────────────────────────────────────┘ 1238 | # [SECTION] [GUI] [Widget] Dialog 1239 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1240 | 1241 | class Dialogs: 1242 | _list = [] 1243 | 1244 | @classmethod 1245 | def dump(cls): 1246 | print(f"Dialogs: {cls._list}") 1247 | 1248 | @classmethod 1249 | def register(cls, dialog): 1250 | cls._list.append(dialog) 1251 | dialog.closeEvent = lambda e: cls.destroy_dialog(dialog) 1252 | 1253 | @classmethod 1254 | def destroy_dialog(cls, dlg): 1255 | cls._list.remove(dlg) 1256 | dlg.deleteLater() 1257 | 1258 | 1259 | @contextmanager 1260 | def Dialog(title=None, size=None, show=True, parent=None): 1261 | if parent is None: 1262 | w = QtGui.QDialog(parent=Gui.getMainWindow()) 1263 | else: 1264 | w = QtGui.QWidget(parent=parent) 1265 | if title is not None: 1266 | w.setWindowTitle(title) 1267 | with build_context().stack(w): 1268 | yield w 1269 | if show: 1270 | Dialogs.register(w) 1271 | w.show() 1272 | if isinstance(size, (tuple,list)): 1273 | w.resize(size[0], size[1]) 1274 | else: 1275 | w.adjustSize() 1276 | 1277 | 1278 | # └────────────────────────────────────────────────────────────────────────────┘ 1279 | # [SECTION] [GUI] [Widget] GroupBox 1280 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1281 | 1282 | @contextmanager 1283 | def GroupBox(title=None): 1284 | w = QtGui.QGroupBox() 1285 | if title: 1286 | w.setTitle(title) 1287 | place_widget(w) 1288 | with build_context().stack(w): 1289 | yield w 1290 | 1291 | 1292 | # └────────────────────────────────────────────────────────────────────────────┘ 1293 | # [SECTION] [GUI] [Widget] Stretch 1294 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1295 | 1296 | def Stretch(stretch=0): 1297 | """Add Layout spacer""" 1298 | layout = build_context().current().layout() 1299 | if layout: 1300 | layout.addStretch(stretch) 1301 | 1302 | 1303 | # └────────────────────────────────────────────────────────────────────────────┘ 1304 | # [SECTION] [GUI] [Widget] TabContainer 1305 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1306 | 1307 | @contextmanager 1308 | def TabContainer(**kwargs): 1309 | w = QtGui.QTabWidget() 1310 | set_qt_attrs(w, **kwargs) 1311 | place_widget(w) 1312 | with build_context().stack(w): 1313 | yield w 1314 | 1315 | 1316 | # └────────────────────────────────────────────────────────────────────────────┘ 1317 | # [SECTION] [GUI] [Widget] Tab 1318 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1319 | 1320 | @contextmanager 1321 | def Tab(title:str, icon=None): 1322 | w = QtGui.QWidget() 1323 | with build_context().stack(w): 1324 | yield w 1325 | if icon: 1326 | build_context().current().addTab(w, title, icon) 1327 | else: 1328 | build_context().current().addTab(w, title) 1329 | 1330 | 1331 | # └────────────────────────────────────────────────────────────────────────────┘ 1332 | # [SECTION] [GUI] [Layout] Col 1333 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1334 | 1335 | @contextmanager 1336 | def Col(add=True, **kwargs): 1337 | """Vertical Layout""" 1338 | yield from setup_layout(QtGui.QVBoxLayout(), add=add, **kwargs) 1339 | 1340 | 1341 | # └────────────────────────────────────────────────────────────────────────────┘ 1342 | # [SECTION] [GUI] [Layout] Row 1343 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1344 | 1345 | @contextmanager 1346 | def Row(add=True, **kwargs): 1347 | """Horizontal Layout""" 1348 | yield from setup_layout(QtGui.QHBoxLayout(), add=add, **kwargs) 1349 | 1350 | 1351 | # └────────────────────────────────────────────────────────────────────────────┘ 1352 | # [SECTION] [GUI] [Widget] TextLabel 1353 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1354 | 1355 | def TextLabel(text="", stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1356 | label = QtGui.QLabel(text) 1357 | set_qt_attrs(label, **kwargs) 1358 | place_widget(label, stretch=stretch, alignment=alignment) 1359 | return label 1360 | 1361 | 1362 | # └────────────────────────────────────────────────────────────────────────────┘ 1363 | # [SECTION] [GUI] [Widget] InputFloat 1364 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1365 | 1366 | def InputFloat(name=None, min=0.0, max=sys.float_info.max, decimals=6, 1367 | step=0.01, label=None, value=0.0, stretch=0, 1368 | alignment=QtCore.Qt.Alignment(), **kwargs): 1369 | editor = QtGui.QDoubleSpinBox() 1370 | editor.setMinimum(min) 1371 | editor.setMaximum(max) 1372 | editor.setSingleStep(step) 1373 | editor.setDecimals(decimals) 1374 | editor.setValue(value) 1375 | set_qt_attrs(editor, **kwargs) 1376 | if name: 1377 | editor.setObjectName(name) 1378 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1379 | return editor 1380 | 1381 | 1382 | # └────────────────────────────────────────────────────────────────────────────┘ 1383 | # [SECTION] [GUI] [Widget] InputInt 1384 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1385 | 1386 | class InputTextWidget(QtGui.QLineEdit): 1387 | def __init__(self, *args, **kwargs): 1388 | super().__init__(*args, **kwargs) 1389 | def value(self): 1390 | return self.text() 1391 | def setValue(self, value): 1392 | self.setText(str(value)) 1393 | 1394 | def InputText(name=None, label=None, value="", 1395 | stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1396 | editor = InputTextWidget() 1397 | editor.setText(value) 1398 | set_qt_attrs(editor, **kwargs) 1399 | if name: 1400 | editor.setObjectName(name) 1401 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1402 | return editor 1403 | 1404 | 1405 | # └────────────────────────────────────────────────────────────────────────────┘ 1406 | # [SECTION] [GUI] [Widget] InputInt 1407 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1408 | 1409 | def InputInt(name=None, min=0, max=2^31, step=1, label=None, value=0, 1410 | stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1411 | editor = QtGui.QSpinBox() 1412 | editor.setMinimum(min) 1413 | editor.setMaximum(max) 1414 | editor.setSingleStep(step) 1415 | editor.setValue(value) 1416 | set_qt_attrs(editor, **kwargs) 1417 | if name: 1418 | editor.setObjectName(name) 1419 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1420 | return editor 1421 | 1422 | 1423 | # └────────────────────────────────────────────────────────────────────────────┘ 1424 | # [SECTION] [GUI] [Widget] InputBoolean 1425 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1426 | 1427 | class QCheckBoxExt(QtGui.QCheckBox): 1428 | def __init__(self, *args, **kwargs): 1429 | super().__init__(*args, **kwargs) 1430 | 1431 | def value(self) -> bool: 1432 | return self.checkState() == QtCore.Qt.Checked 1433 | 1434 | def setValue(self, value : bool): 1435 | self.setCheckState(QtCore.Qt.Checked if value else QtCore.Qt.Unchecked) 1436 | 1437 | 1438 | def InputBoolean(name=None, label=None, value=False, stretch=0, 1439 | alignment=QtCore.Qt.Alignment(), **kwargs): 1440 | editor = QCheckBoxExt() 1441 | editor.setValue(value) 1442 | set_qt_attrs(editor, **kwargs) 1443 | if name: 1444 | editor.setObjectName(name) 1445 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1446 | return editor 1447 | 1448 | # └────────────────────────────────────────────────────────────────────────────┘ 1449 | # [SECTION] [GUI] [Widget] InputVector 1450 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1451 | 1452 | class InputVectorWrapper: 1453 | def __init__(self, g, x, y, z): 1454 | self.group = g 1455 | self.x = x 1456 | self.y = y 1457 | self.z = z 1458 | 1459 | def value(self) -> Vec: 1460 | return Vec(self.x.value(), self.y.value(), self.z.value()) 1461 | 1462 | def setValue(self, value): 1463 | v = to_vec(value) 1464 | self.x.setValue(v.x) 1465 | self.y.setValue(v.y) 1466 | self.z.setValue(v.z) 1467 | 1468 | def InputVector(label=None, value=(0.0,0.0,0.0)): 1469 | with GroupBox(title=label) as g: 1470 | with Col(): 1471 | x = InputFloat(label="X:") 1472 | y = InputFloat(label="Y:") 1473 | z = InputFloat(label="Z:") 1474 | widget = InputVectorWrapper(g, x, y, z) 1475 | widget.setValue(value) 1476 | return widget 1477 | 1478 | 1479 | # └────────────────────────────────────────────────────────────────────────────┘ 1480 | # [SECTION] [GUI] [Widget] InputOptions 1481 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1482 | 1483 | class InputOptionsWrapper: 1484 | def __init__(self, combobox:QtGui.QComboBox, data: Dict[str,Any]): 1485 | self.combobox = combobox 1486 | self.index = dict() 1487 | self.lookup = dict() 1488 | i = 0 1489 | for label, value in data.items(): 1490 | self.index[i] = value 1491 | self.lookup[value] = i 1492 | i += 1 1493 | combobox.addItem(label) 1494 | 1495 | def value(self): 1496 | return self.index.get(self.combobox.currentIndex(), None) 1497 | 1498 | def setValue(self, value): 1499 | index = self.lookup.get(value, None) 1500 | if index is not None: 1501 | self.combobox.setCurrentIndex(index) 1502 | 1503 | def InputOptions(options, value=None, label=None, name=None, stretch=0, 1504 | alignment=QtCore.Qt.Alignment(), **kwargs): 1505 | widget = QtGui.QComboBox() 1506 | set_qt_attrs(widget, **kwargs) 1507 | editor = InputOptionsWrapper(widget, options) 1508 | if value is not None: 1509 | editor.setValue(value) 1510 | if name: 1511 | editor.combobox.setObjectName(name) 1512 | place_widget(editor.combobox, label=label, stretch=stretch, alignment=alignment) 1513 | return editor 1514 | 1515 | 1516 | # └────────────────────────────────────────────────────────────────────────────┘ 1517 | # [SECTION] [GUI] [Widget] InputSelectOne 1518 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1519 | 1520 | class InputSelectOne: 1521 | 1522 | def __init__(self, label=None, name=None, active=False, auto_deactivate=True): 1523 | self._value = None 1524 | self._pre = None 1525 | self._auto_deactivate = auto_deactivate 1526 | self.selected = PySignal() 1527 | with Row(add=False, spacing=0, margin=0, contentsMargins=(0,0,0,0)) as ctl: 1528 | 1529 | @button( 1530 | text="Select...", 1531 | tool=True, 1532 | checkable=True, 1533 | styleSheet="QToolButton:checked{background-color: #FF0000; color:#FFFFFF;}", 1534 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1535 | objectName=name, 1536 | checked=active) 1537 | def select(): pass 1538 | 1539 | @button( 1540 | tool=True, 1541 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1542 | icon=Icon(':icons/edit-cleartext.svg')) 1543 | def clear(): self.setValue(None) 1544 | 1545 | display = QtGui.QLineEdit() 1546 | display.setReadOnly(True) 1547 | place_widget(display) 1548 | 1549 | self.display = display 1550 | self.button = select 1551 | register_select_observer(select, self) 1552 | 1553 | with Parent(): 1554 | place_widget(ctl, label=label) 1555 | 1556 | @property 1557 | def active(self) -> bool: 1558 | return self.button.isChecked() 1559 | 1560 | def value(self) -> Optional[SelectedObject]: 1561 | return self._value 1562 | 1563 | def pre(self) -> Optional[SelectedObject]: 1564 | return self._pre 1565 | 1566 | def setValue(self, value: Optional[SelectedObject]) -> None: 1567 | self._value = value 1568 | if value: 1569 | self.display.setText(f"{value.doc}#{value.obj}.{value.sub}") 1570 | if self._auto_deactivate: 1571 | self.button.setChecked(False) 1572 | self.selected.trigger(self._value) 1573 | else: 1574 | self.display.setText(f"") 1575 | 1576 | def setPreselection(self, doc, obj, sub): 1577 | if self.button.isChecked(): 1578 | self._pre = SelectedObject(doc, obj, sub) 1579 | 1580 | def addSelection(self, doc, obj, sub, pnt): 1581 | if self.button.isChecked(): 1582 | self.setValue(SelectedObject(doc, obj, sub, pnt)) 1583 | 1584 | def removeSelection(self, doc, obj, sub): 1585 | if self.button.isChecked(): 1586 | if self._value: 1587 | v = self._value 1588 | if (v.doc, v.obj) == (doc, obj): 1589 | self.setValue(None) 1590 | 1591 | def setSelection(self, doc): 1592 | if self.button.isChecked(): 1593 | self.setValue(SelectedObject(doc, Gui.Selection.getSelection()[-1].Name)) 1594 | 1595 | def clearSelection(self, doc): 1596 | pass 1597 | 1598 | 1599 | # └────────────────────────────────────────────────────────────────────────────┘ 1600 | # [SECTION] [GUI] [Widget] InputSelectMany 1601 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1602 | 1603 | class InputSelectMany: 1604 | 1605 | ValueDataRole = QtCore.Qt.UserRole 1606 | 1607 | def __init__(self, label=None, name=None, active=False): 1608 | self._value = set() 1609 | self.selected = PySignal() 1610 | with Col(add=False, spacing=0, margin=0, contentsMargins=(0,0,0,0)) as ctl: 1611 | with Row(spacing=0, margin=0, contentsMargins=(0,0,0,0)): 1612 | @button( 1613 | text="Add", 1614 | alignment=QtCore.Qt.AlignLeft, 1615 | tool=True, 1616 | checkable=True, 1617 | styleSheet="QToolButton:checked{background-color: #FF0000; color:#FFFFFF;}", 1618 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1619 | objectName=name, 1620 | checked=active) 1621 | def select(): pass 1622 | 1623 | @button( 1624 | text="Remove", 1625 | tool=True, 1626 | alignment=QtCore.Qt.AlignLeft, 1627 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus) 1628 | def remove(): 1629 | selected = self.display.selectedItems() 1630 | for item in selected: 1631 | value = item.data(0, InputSelectMany.ValueDataRole) 1632 | self._value.remove(value) 1633 | self.display.takeTopLevelItem(self.display.indexOfTopLevelItem(item)) 1634 | 1635 | @button( 1636 | text="Clean", 1637 | tool=True, 1638 | alignment=QtCore.Qt.AlignLeft, 1639 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1640 | icon=Icon(':icons/edit-cleartext.svg')) 1641 | def clear(): 1642 | self._value.clear() 1643 | self.display.clear() 1644 | 1645 | Stretch() 1646 | 1647 | display = QtGui.QTreeWidget() 1648 | display.setColumnCount(2) 1649 | display.setHeaderLabels(['Object', 'SubObject']) 1650 | place_widget(display) 1651 | 1652 | self.display = display 1653 | self.button = select 1654 | register_select_observer(select, self) 1655 | 1656 | with Parent(): 1657 | with GroupBox(title=label): 1658 | place_widget(ctl) 1659 | 1660 | @property 1661 | def active(self) -> bool: 1662 | return self.button.isChecked() 1663 | 1664 | def value(self) -> List[SelectedObject]: 1665 | return self._value 1666 | 1667 | def addValue(self, value: SelectedObject) -> None: 1668 | if value not in self._value: 1669 | item = QtGui.QTreeWidgetItem([value.obj, value.sub]) 1670 | item.setData(0, InputSelectMany.ValueDataRole, value) 1671 | self.display.addTopLevelItem(item) 1672 | self._value.add(value) 1673 | self.selected.trigger(value) 1674 | 1675 | def setPreselection(self, doc, obj, sub): 1676 | pass 1677 | 1678 | def addSelection(self, doc, obj, sub, pnt): 1679 | if self.button.isChecked(): 1680 | self.addValue(SelectedObject(doc, obj, sub, pnt)) 1681 | 1682 | def removeSelection(self, doc, obj, sub): 1683 | pass 1684 | 1685 | def setSelection(self, doc): 1686 | if self.button.isChecked(): 1687 | self.addValue(SelectedObject(doc, Gui.Selection.getSelection()[-1].Name)) 1688 | 1689 | def clearSelection(self, doc): 1690 | pass 1691 | 1692 | 1693 | # └────────────────────────────────────────────────────────────────────────────┘ 1694 | # [SECTION] [GUI] [Widget] button 1695 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1696 | 1697 | def button(label=None, add:bool=True, tool:bool=False, stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1698 | if tool: 1699 | btn = QtGui.QToolButton() 1700 | else: 1701 | btn = QtGui.QPushButton() 1702 | set_qt_attrs(btn, **kwargs) 1703 | if label: 1704 | btn.setText(label) 1705 | elif 'text' not in kwargs: 1706 | btn.setText("Button") 1707 | if add: 1708 | place_widget(btn, stretch=stretch, alignment=alignment) 1709 | def wrapper(handler): 1710 | btn.clicked.connect(handler) 1711 | return btn 1712 | return wrapper 1713 | 1714 | 1715 | # └────────────────────────────────────────────────────────────────────────────┘ 1716 | # [SECTION] [GUI] progress_indicator 1717 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1718 | 1719 | class ProgressIndicator: 1720 | def __init__(self, *args, **kwargs) -> None: 1721 | try: 1722 | self.control = Base.ProgressIndicator(*args, **kwargs) 1723 | except: 1724 | self.control = None 1725 | def start(self, *args, **kwargs): 1726 | if self.control: 1727 | self.control.start(*args, **kwargs) 1728 | def next(self, *args, **kwargs): 1729 | if self.control: 1730 | self.control.next(*args, **kwargs) 1731 | def stop(self, *args, **kwargs): 1732 | if self.control: 1733 | self.control.stop(*args, **kwargs) 1734 | 1735 | @contextmanager 1736 | def progress_indicator(message: str = "Working...", steps: int = 0): 1737 | bar = ProgressIndicator() 1738 | bar.start(message, steps) 1739 | try: 1740 | yield bar 1741 | finally: 1742 | bar.stop() 1743 | del bar 1744 | 1745 | 1746 | # └────────────────────────────────────────────────────────────────────────────┘ 1747 | # [SECTION] [GUI] Message Boxes 1748 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1749 | 1750 | def show_msgbox(message, title="Information", std_icon=QtGui.QMessageBox.Information, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1751 | diag = QtGui.QMessageBox(std_icon, title, message, std_buttons, parent) 1752 | diag.setWindowModality(QtCore.Qt.ApplicationModal) 1753 | diag.exec_() 1754 | 1755 | 1756 | def show_warning(message, title="Warning", std_icon=QtGui.QMessageBox.Warning, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1757 | show_msgbox(message, title, std_icon, std_buttons, parent) 1758 | 1759 | 1760 | def show_error(message, title="Error", std_icon=QtGui.QMessageBox.Critical, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1761 | show_msgbox(message, title, std_icon, std_buttons, parent) 1762 | 1763 | 1764 | show_info = show_msgbox 1765 | 1766 | 1767 | # └────────────────────────────────────────────────────────────────────────────┘ 1768 | # [SECTION] [TEST] 1769 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1770 | 1771 | -------------------------------------------------------------------------------- /freecad/fcscript/v_0_0_2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2022 Frank David Martinez M. 4 | # 5 | # This file is part of FCScript. 6 | # 7 | # FCScript is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Utils is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with FCScript. If not, see . 19 | # 20 | 21 | # └────────────────────────────────────────────────────────────────────────────┘ 22 | # [SECTION] Common Builtin Imports 23 | # ┌────────────────────────────────────────────────────────────────────────────┐ 24 | 25 | from cmath import exp 26 | from contextlib import contextmanager 27 | from enum import Enum 28 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Union 29 | from pathlib import Path 30 | import math 31 | import sys 32 | import threading 33 | import json 34 | 35 | try: 36 | from typing import Protocol, runtime_checkable 37 | except: 38 | from typing_extensions import Protocol, runtime_checkable 39 | 40 | 41 | # └────────────────────────────────────────────────────────────────────────────┘ 42 | # [SECTION] FreeCAD Imports 43 | # ┌────────────────────────────────────────────────────────────────────────────┐ 44 | 45 | from FreeCAD import Base 46 | import FreeCAD as App 47 | import Part 48 | import Sketcher 49 | from ProfileLib import RegularPolygon 50 | import FreeCADGui as Gui 51 | 52 | 53 | # └────────────────────────────────────────────────────────────────────────────┘ 54 | # [SECTION] [Lang] Typing 55 | # ┌────────────────────────────────────────────────────────────────────────────┐ 56 | 57 | @runtime_checkable 58 | class ObjectWithOrigin(Protocol): 59 | @property 60 | def Origin(self) -> App.DocumentObject: 61 | ... 62 | 63 | 64 | # └────────────────────────────────────────────────────────────────────────────┘ 65 | # [SECTION] [FreeCAD] Aliases 66 | # ┌────────────────────────────────────────────────────────────────────────────┐ 67 | 68 | #: Point/Vector alias 69 | Pnt = Base.Vector 70 | 71 | #: Vector alias 72 | Vec = Base.Vector 73 | 74 | #: App.Rotation alias 75 | Rotation = App.Rotation 76 | 77 | #: Quantity converter from string 78 | Quantity = App.Units.Quantity 79 | 80 | #: Commands 81 | command = Gui.runCommand 82 | 83 | # └────────────────────────────────────────────────────────────────────────────┘ 84 | # [SECTION] [Constants] Common constants 85 | # ┌────────────────────────────────────────────────────────────────────────────┐ 86 | 87 | #: Geometry start point (except circles or ellipses) 88 | GeomStart = 1 89 | 90 | #: Geometry end point (except circles or ellipses) 91 | GeomEnd = 2 92 | 93 | #: Geometry center point (only circles and ellipses) 94 | GeomCenter = 3 95 | 96 | #: X Axis Geometry index in Sketch 97 | XAxisIndex = -1 98 | 99 | #: Y Axis Geometry index in Sketch 100 | YAxisIndex = -2 101 | 102 | 103 | # └────────────────────────────────────────────────────────────────────────────┘ 104 | # [SECTION] [Util] Common utilities 105 | # ┌────────────────────────────────────────────────────────────────────────────┐ 106 | 107 | def to_vec(input : Any) -> Vec: 108 | """Convert tuple/list/vector to Vec.""" 109 | if isinstance(input, Vec): 110 | return input 111 | if isinstance(input, (tuple, list)): 112 | if len(input) == 3: 113 | return Vec(*input) 114 | if len(input) == 2: 115 | return Vec(*input, 0) 116 | if len(input) == 1: 117 | return Vec(*input, 0, 0) 118 | if hasattr(input, "X"): 119 | if hasattr(input, "Y"): 120 | if hasattr(input, "Z"): 121 | return Vec(input.X, input.Y, input.Z) 122 | else: 123 | return Vec(input.X, input.Y, 0) 124 | else: 125 | return Vec(input.X, 0, 0) 126 | if isinstance(input, (float, int)): 127 | return Vec(input, 0, 0) 128 | raise RuntimeError(f"Invalid input, {type(input)} is not convertible to Vec") 129 | 130 | 131 | def to_vecs(*input : Any) -> Tuple[Vec, ...]: 132 | """Convert arguments into Vectors. See to_vec.""" 133 | return tuple(to_vec(i) for i in input) 134 | 135 | 136 | def find_obj_origin_geo_feature(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 137 | """Extract Axes or Planes from object's Origin""" 138 | for geo in obj.Origin.OutList: 139 | if geo.Name.startswith(prefix): 140 | return geo 141 | 142 | 143 | def find_obj_origin_axis(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 144 | """Extract Axes from object's Origin""" 145 | return find_obj_origin_geo_feature(obj, f'{prefix.upper()}_Axis') 146 | 147 | 148 | def find_obj_origin_plane(obj: ObjectWithOrigin, prefix: str) -> App.GeoFeature: 149 | """Extract Planes from object's Origin""" 150 | return find_obj_origin_geo_feature(obj, f'{prefix.upper()}_Plane') 151 | 152 | 153 | def recompute(): 154 | """Recompute the whole active document""" 155 | App.ActiveDocument.recompute() 156 | 157 | 158 | def Dx(v: Union[float, Vec]): 159 | """Vector in x direction""" 160 | if isinstance(v, (float, int)): 161 | return Vec(v, 0, 0) 162 | return Vec(v.X, 0, 0) 163 | 164 | 165 | def Dy(v: Union[float, Vec]): 166 | """Vector in y direction""" 167 | if isinstance(v, (float, int)): 168 | return Vec(0, v, 0) 169 | return Vec(0, v.Y, 0) 170 | 171 | 172 | def Dz(v: Union[float, Vec]): 173 | """Vector in z direction""" 174 | if isinstance(v, (float, int)): 175 | return Vec(0, 0, v) 176 | return Vec(0, 0, v.Z) 177 | 178 | #: Decorator 179 | def set_function(target, attribute): 180 | """Decorator: Set function to an attribute of target.""" 181 | def deco(fn): 182 | setattr(target, attribute, fn) 183 | return fn 184 | return deco 185 | 186 | 187 | def clamp(value, min_, max_): 188 | return max(min_, min(max_, value)) 189 | 190 | 191 | def dir_of_2points(p1, p2): 192 | return (p2 - p1).normalize() 193 | 194 | 195 | def get_macros_dir(): 196 | Standard, UserDefined = False, True 197 | root = Path(App.getUserMacroDir(UserDefined)) 198 | if not root.exists(): 199 | root = Path(App.getUserMacroDir(Standard)) 200 | return root 201 | 202 | 203 | # └────────────────────────────────────────────────────────────────────────────┘ 204 | # [SECTION] [Expression] Expression Engine Utilities 205 | # ┌────────────────────────────────────────────────────────────────────────────┐ 206 | # 207 | # Example 1 (Eval): 208 | # e1 = Expr(".Length * 3 + <>.Radius") 209 | # result = e1(App.ActiveDocument.Box) 210 | # 211 | # Example 2 (Set): 212 | # e1.set_to(App.ActiveDocument.Pad002, 'Length') 213 | # 214 | # Example 3 (Set in place) 215 | # bind_expr(App.ActiveDocument.Pad002, 'Length', ".Length * 3 + <>.Radius") 216 | # 217 | 218 | class Expr: 219 | """Callable expression (FreeCAD's Expression Engine)""" 220 | 221 | def __init__(self, value: str, context: Any = None): 222 | self.value = value 223 | self.context = context 224 | 225 | def __call__(self, context: Any = None) -> Any: 226 | return (context or self.context).evalExpression(self.value) 227 | 228 | def set_to(self, obj, property: str, auto_recompute=False): 229 | obj.setExpression(property, self.value) 230 | if auto_recompute: 231 | recompute() 232 | 233 | def bind_expr(obj: Any, property: str, expr: Union[str, Expr], auto_recompute: bool = False): 234 | if not isinstance(expr, Expr): 235 | expr = Expr(expr, obj) 236 | expr.set_to(obj, property, auto_recompute) 237 | 238 | 239 | # └────────────────────────────────────────────────────────────────────────────┘ 240 | # [SECTION] [Sketch] Geometry 241 | # ┌────────────────────────────────────────────────────────────────────────────┐ 242 | 243 | class SketchGeom: 244 | """Geometry info inside a sketch""" 245 | def __init__(self, index, obj, name=None): 246 | self.index = index 247 | self.obj = obj 248 | self.name = name 249 | 250 | 251 | # └────────────────────────────────────────────────────────────────────────────┘ 252 | # [SECTION] [Sketch] Solver 253 | # ┌────────────────────────────────────────────────────────────────────────────┐ 254 | 255 | class Solver(Enum): 256 | BFGSSolver = 0 257 | LevenbergMarquardtSolver = 1 258 | DogLegSolver = 2 259 | 260 | 261 | # └────────────────────────────────────────────────────────────────────────────┘ 262 | # [SECTION] [Sketch] Geometric Quantities 263 | # ┌────────────────────────────────────────────────────────────────────────────┐ 264 | 265 | class gq: 266 | 267 | class Base: 268 | value: float 269 | def __init__(self, value: Union[float,str]): 270 | if isinstance(value, str): 271 | self.value = Quantity(str) 272 | else: 273 | self.value = value 274 | 275 | class Radius(Base): 276 | def __init__(self, value: Union[float,str]): 277 | super().__init__(value) 278 | 279 | class Diameter(Base): 280 | def __init__(self, value: Union[float,str]): 281 | super().__init__(value) 282 | 283 | class Length(Base): 284 | def __init__(self, value: Union[float,str]): 285 | super().__init__(value) 286 | 287 | class Angle(Base): 288 | def __init__(self, value: Union[float,str]): 289 | super().__init__(value) 290 | 291 | class DeltaVec(Base): 292 | def __init__(self, value: any): 293 | self.value = to_vec(value) 294 | 295 | # └────────────────────────────────────────────────────────────────────────────┘ 296 | # [SECTION] [Sketch] Sketch 297 | # ┌────────────────────────────────────────────────────────────────────────────┐ 298 | 299 | class XSketch: 300 | """Sketch Wrapper""" 301 | 302 | DEFAULT_SOLVER : Solver = Solver.DogLegSolver 303 | 304 | @staticmethod 305 | def select_default_solver(solver: Solver): 306 | App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher').SetBool('ShowSolverAdvancedWidget', True) 307 | App.ParamGet('User parameter:BaseApp/Preferences/Mod/Sketcher/SolverAdvanced').SetInt('DefaultSolver', solver.value) 308 | 309 | 310 | def __init__(self, name: str = 'XSketch', parent: Any = None, clean: bool = True): 311 | XSketch.select_default_solver(XSketch.DEFAULT_SOLVER) 312 | if parent: 313 | name = f"{parent.Name}_{name}" 314 | self.obj = App.ActiveDocument.getObject(name) 315 | if self.obj: 316 | if clean: 317 | self.obj.deleteAllGeometry() 318 | else: 319 | if parent: 320 | self.obj = parent.newObject("Sketcher::SketchObject", name) 321 | else: 322 | self.obj = App.ActiveDocument.addObject("Sketcher::SketchObject", name) 323 | 324 | self.named_geom = {} 325 | self._ref_mode = False 326 | self.parent = parent 327 | 328 | 329 | @contextmanager 330 | def ref_mode(self, mode : bool = True): 331 | saved = self._ref_mode 332 | if saved == mode: 333 | yield self 334 | else: 335 | self._ref_mode = mode 336 | try: 337 | yield self 338 | finally: 339 | self._ref_mode = saved 340 | 341 | 342 | def set_geom_mode(self): 343 | self._ref_mode = False 344 | 345 | # └────────────────────────────────────────────────────────────────────────────┘ 346 | # [SECTION] [Sketch/Geometry] 347 | # ┌────────────────────────────────────────────────────────────────────────────┐ 348 | 349 | def g_line(self, start, end): 350 | """Draw a line segment from start to end""" 351 | p0, p1 = to_vec(start), to_vec(end) 352 | line = Part.LineSegment(p0, p1) 353 | index = self.obj.addGeometry(line, self._ref_mode) 354 | return SketchGeom(index, line) 355 | 356 | 357 | def g_regular_polygon(self, p1, p2, edges): 358 | """Draw a regular polygon (center, vertex, edges).""" 359 | RegularPolygon.makeRegularPolygon(self.obj, edges, to_vec(p1), to_vec(p2), self._ref_mode) 360 | index = len(self.obj.Geometry) - 1 361 | return SketchGeom(index, self.obj.Geometry[index]) 362 | 363 | 364 | def g_bspline(self, poles, mults=None, knots=None, periodic=False, degree=3, weights=None, check_rational=False): 365 | """Draw a bspline""" 366 | vec_poles = [to_vec(p) for p in poles] 367 | bspline = Part.BSplineCurve(vec_poles, mults, knots, periodic, degree, weights, check_rational) 368 | index = self.obj.addGeometry(bspline, self._ref_mode) 369 | return SketchGeom(index, bspline) 370 | 371 | 372 | def g_circle_center_radius(self, cnt, rad): 373 | c = to_vec(cnt) 374 | circle = Part.Circle(c, Vec(0,0,1), rad) 375 | index = self.obj.addGeometry(circle, self._ref_mode) 376 | return SketchGeom(index, circle) 377 | 378 | 379 | def g_circle_3points(self, p1, p2, p3): 380 | v1, v2, v3 = to_vecs(p1, p2, p3) 381 | circle = Part.Circle(v1, v2, v3) 382 | index = self.obj.addGeometry(circle, self._ref_mode) 383 | return SketchGeom(index, circle) 384 | 385 | 386 | def g_arc_3points(self, p1, p2, p3): 387 | v1, v2, v3 = to_vecs(p1, p2, p3) 388 | arc = Part.Arc(v1, v2, v3) 389 | index = self.obj.addGeometry(arc, self._ref_mode) 390 | return SketchGeom(index, arc) 391 | 392 | 393 | def g_arc_center_radius(self, cnt, rad, start=0, end=math.radians(180)): 394 | c = to_vec(cnt) 395 | circle = Part.Circle(c, Vec(0,0,1), rad) 396 | arc = Part.ArcOfCircle(circle, start, end) 397 | index = self.obj.addGeometry(arc, self._ref_mode) 398 | return SketchGeom(index, arc) 399 | 400 | 401 | def g_point(self, p): 402 | pnt = Part.Point(p) 403 | index = self.obj.addGeometry(pnt, self._ref_mode) 404 | return SketchGeom(index, pnt) 405 | 406 | # └────────────────────────────────────────────────────────────────────────────┘ 407 | # [SECTION] [Sketch/Constraints] 408 | # ┌────────────────────────────────────────────────────────────────────────────┐ 409 | 410 | def rename_constraint(self, constraint, name): 411 | if name: 412 | self.obj.renameConstraint(constraint, name) 413 | 414 | 415 | def c_coincident(self, g1, g1c, g2, g2c, name=None): 416 | c = self.obj.addConstraint(Sketcher.Constraint("Coincident", g1, g1c, g2, g2c)) 417 | self.rename_constraint(c, name) 418 | return c 419 | 420 | 421 | def c_vertical(self, index, name=None): 422 | c = self.obj.addConstraint(Sketcher.Constraint("Vertical", index)) 423 | self.rename_constraint(c, name) 424 | return c 425 | 426 | 427 | def c_horizontal(self, index, name=None): 428 | c = self.obj.addConstraint(Sketcher.Constraint("Horizontal", index)) 429 | self.rename_constraint(c, name) 430 | return c 431 | 432 | 433 | def c_coincident_end_start(self, g1, g2, name=None): 434 | g_prev = self.obj.Geometry[g1] 435 | g_current = self.obj.Geometry[g2] 436 | c_end = GeomEnd if hasattr(g_prev, "EndPoint") else GeomCenter 437 | c_start = GeomStart if hasattr(g_current, "StartPoint") else GeomCenter 438 | c = self.c_coincident(g1, c_end, g2, c_start) 439 | self.rename_constraint(c, name) 440 | return c 441 | 442 | 443 | def c_x_angle(self, index: int, angle: Union[float, Expr], name=None): 444 | return self.c_angle(XAxisIndex, GeomStart, index, GeomStart, angle, name=name) 445 | 446 | 447 | def c_y_angle(self, index, angle: Union[float, Expr], name=None): 448 | return self.c_angle(YAxisIndex, GeomStart, index, GeomStart, angle, name=name) 449 | 450 | 451 | def c_angle(self, g1, g1c, g2, g2c, angle: Union[float, Expr], name=None): 452 | if isinstance(angle, Expr): 453 | value = angle(self.obj) 454 | c = self.obj.addConstraint(Sketcher.Constraint("Angle", g1, g1c, g2, g2c, value)) 455 | angle.set_to(self.obj, f'.Constraints[{c}]') 456 | else: 457 | c = self.obj.addConstraint(Sketcher.Constraint("Angle", g1, g1c, g2, g2c, angle)) 458 | self.rename_constraint(c, name) 459 | return c 460 | 461 | 462 | def c_length(self, index, length: Union[float, Expr], name=None): 463 | if isinstance(length, Expr): 464 | value = length(self.obj) 465 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", index, value)) 466 | length.set_to(self.obj, f'.Constraints[{c}]') 467 | else: 468 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", index, length)) 469 | self.rename_constraint(c, name) 470 | return c 471 | 472 | 473 | def c_distance(self, g1, g1p, g2, g2p, dist: Union[float, Expr], name=None): 474 | if isinstance(dist, Expr): 475 | value = dist(self.obj) 476 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", g1, g1p, g2, g2p, value)) 477 | dist.set_to(self.obj, f'.Constraints[{c}]') 478 | else: 479 | c = self.obj.addConstraint(Sketcher.Constraint("Distance", g1, g1p, g2, g2p, dist)) 480 | self.rename_constraint(c, name) 481 | return c 482 | 483 | 484 | def c_perpendicular(self, g1, g2, name=None): 485 | c = self.obj.addConstraint(Sketcher.Constraint("Perpendicular", g1, g2)) 486 | self.rename_constraint(c, name) 487 | return c 488 | 489 | 490 | def c_parallel(self, g1, g2, name=None): 491 | c = self.obj.addConstraint(Sketcher.Constraint("Parallel", g1, g2)) 492 | self.rename_constraint(c, name) 493 | return c 494 | 495 | 496 | def _c_xy(self, g, distance, type, anchor=GeomStart, name=None): 497 | if isinstance(distance, Expr): 498 | value = distance(self.obj) 499 | c = self.obj.addConstraint(Sketcher.Constraint(f"Distance{type}", g, anchor, value)) 500 | distance.set_to(self.obj, f'.Constraints[{c}]') 501 | else: 502 | c = self.obj.addConstraint(Sketcher.Constraint(f"Distance{type}", g, anchor, distance)) 503 | self.rename_constraint(c, name) 504 | return c 505 | 506 | 507 | def c_x(self, g, distance, anchor=GeomStart, name=None): 508 | return self._c_xy(g, distance, 'X', anchor, name) 509 | 510 | 511 | def c_y(self, g, distance, anchor=GeomStart, name=None): 512 | return self._c_xy(g, distance, 'Y', anchor, name) 513 | 514 | 515 | def c_xy(self, g, x, y, anchor=GeomStart, name=None): 516 | c1 = self.c_x(g, x, anchor=anchor, name=None if name is None else f'{name}_x') 517 | c2 = self.c_y(g, y, anchor=anchor, name=None if name is None else f'{name}_y') 518 | return c1, c2 519 | 520 | 521 | def c_point_on_object(self, pnt, obj, name=None): 522 | c = self.obj.addConstraint(Sketcher.Constraint("PointOnObject", pnt, GeomStart, obj)) 523 | self.rename_constraint(c, name) 524 | return c 525 | 526 | 527 | def c_equal(self, g1, g2, name=None): 528 | c = self.obj.addConstraint(Sketcher.Constraint("Equal", g1, g2)) 529 | self.rename_constraint(c, name) 530 | return c 531 | 532 | 533 | def c_diameter(self, g, diameter, name=None): 534 | if isinstance(diameter, Expr): 535 | value = diameter(self.obj) 536 | c = self.obj.addConstraint(Sketcher.Constraint("Diameter", g, value)) 537 | diameter.set_to(self.obj, f'.Constraints[{c}]') 538 | else: 539 | c = self.obj.addConstraint(Sketcher.Constraint("Diameter", g, diameter)) 540 | self.rename_constraint(c, name) 541 | return c 542 | 543 | 544 | def c_radius(self, g, radius, name=None): 545 | if isinstance(radius, Expr): 546 | value = radius(self.obj) 547 | c = self.obj.addConstraint(Sketcher.Constraint("Radius", g, value)) 548 | radius.set_to(self.obj, f'.Constraints[{c}]') 549 | else: 550 | c = self.obj.addConstraint(Sketcher.Constraint("Radius", g, radius)) 551 | self.rename_constraint(c, name) 552 | return c 553 | 554 | 555 | def c_auto_coincident(self, name=None): 556 | g_len = len(self.obj.Geometry) 557 | if g_len > 1: 558 | return self.c_coincident_end_start(g_len-2, g_len-1, name=name) 559 | 560 | 561 | def c_bspline_control_point(self, g, g_c, bspl, bspl_c, weight=1.0): 562 | with self.ref_mode(): 563 | geom = self.obj.Geometry[g] 564 | pnt = None 565 | if g_c == GeomStart: 566 | if hasattr(geom, "StartPoint"): 567 | pnt = geom.StartPoint 568 | elif hasattr(geom, "X"): 569 | pnt = Pnt(geom.X, geom.Y, geom.Z) 570 | elif g_c == GeomEnd: 571 | if hasattr(geom, "EndPoint"): 572 | pnt = geom.EndPoint 573 | elif hasattr(geom, "X"): 574 | pnt = Pnt(geom.X, geom.Y, geom.Z) 575 | elif g_c == GeomCenter: 576 | if hasattr(geom, "Center"): 577 | pnt = geom.Center 578 | elif hasattr(geom, "X"): 579 | pnt = Pnt(geom.X, geom.Y, geom.Z) 580 | if pnt is None: 581 | raise RuntimeError(f"Unsupported constraint {g}.{g_c}") 582 | 583 | cpc = self.g_circle_center_radius(pnt, 10) 584 | self.obj.addConstraint(Sketcher.Constraint('Weight', cpc.index, weight)) 585 | self.obj.addConstraint(Sketcher.Constraint('Coincident', cpc.index, GeomCenter, g, g_c)) 586 | self.obj.addConstraint(Sketcher.Constraint('InternalAlignment:Sketcher::BSplineControlPoint', cpc.index, GeomCenter, bspl, bspl_c)) 587 | 588 | # └────────────────────────────────────────────────────────────────────────────┘ 589 | # [SECTION] [Sketch/Lookup] 590 | # ┌────────────────────────────────────────────────────────────────────────────┐ 591 | 592 | def g_by_name(self, name): 593 | index = self.named_geom.get(name, None) 594 | if index: 595 | return SketchGeom(index, self.obj.Geometry[index], name) 596 | 597 | def g_by_index(self, index): 598 | return SketchGeom(index, self.obj.Geometry[index]) 599 | 600 | def set_name(self, name, geom): 601 | if name in self.named_geom: 602 | raise RuntimeError("Duplicated name") 603 | if isinstance(geom, SketchGeom): 604 | self.named_geom[name] = geom.index 605 | else: 606 | self.named_geom[name] = geom 607 | 608 | # └────────────────────────────────────────────────────────────────────────────┘ 609 | # [SECTION] [Sketch/Builders] 610 | # ┌────────────────────────────────────────────────────────────────────────────┐ 611 | 612 | def create_group(self, auto_coincident=True): 613 | return XSketchGroup(self, auto_coincident) 614 | 615 | # └────────────────────────────────────────────────────────────────────────────┘ 616 | # [SECTION] [Sketch/Placement] 617 | # ┌────────────────────────────────────────────────────────────────────────────┐ 618 | 619 | def rotate(self, angle): 620 | self.obj.AttachmentOffset = App.Placement( 621 | self.obj.AttachmentOffset.Base, 622 | App.Rotation(self.obj.AttachmentOffset.Rotation.Axis, angle) 623 | ) 624 | 625 | def move(self, pos): 626 | self.obj.AttachmentOffset = App.Placement( 627 | to_vec(pos), 628 | self.obj.AttachmentOffset.Rotation 629 | ) 630 | 631 | def attach(self, pos, axis, angle): 632 | self.obj.AttachmentOffset = App.Placement( 633 | to_vec(pos), 634 | App.Rotation(to_vec(axis), angle) 635 | ) 636 | 637 | # └────────────────────────────────────────────────────────────────────────────┘ 638 | # [SECTION] [Sketch/PartDesign] 639 | # ┌────────────────────────────────────────────────────────────────────────────┐ 640 | 641 | def pad(self, value, name='Pad', direction=None): 642 | name = f"{self.obj.Name}_{name}" 643 | if self.parent: 644 | feature = self.parent.getObject(name) 645 | if not feature: 646 | feature = self.parent.newObject('PartDesign::Pad', name) 647 | else: 648 | if feature.TypeId != 'PartDesign::Pad': 649 | raise RuntimeError(f'The named object "{name}" already exists but it is not a PartDesign::Pad') 650 | feature.Profile = self.obj 651 | feature.Length = value 652 | if direction: 653 | feature.UseCustomVector = True 654 | feature.Direction = direction 655 | return feature 656 | 657 | 658 | # └────────────────────────────────────────────────────────────────────────────┘ 659 | # [SECTION] [Sketch] Group 660 | # ┌────────────────────────────────────────────────────────────────────────────┐ 661 | 662 | class XSketchGroup: 663 | 664 | def __init__(self, sketch: XSketch, auto_coincident: bool=True): 665 | self.sketch = sketch 666 | self.begin = None 667 | self.auto_coincident = auto_coincident 668 | self.pos = Pnt(0,0,0) 669 | 670 | def _concat(self, pnt, g, name): 671 | if self.begin is not None and g.index > 0 and self.auto_coincident: 672 | self.sketch.c_auto_coincident() 673 | self.pos = pnt 674 | if self.begin is None: 675 | self.begin = g.index 676 | if name: 677 | self.sketch.set_name(name, g) 678 | 679 | def line_to(self, pnt_, name=None): 680 | pnt = to_vec(pnt_) 681 | g = self.sketch.g_line(self.pos, pnt) 682 | self._concat(pnt, g, name) 683 | return g 684 | 685 | def line(self, dx=0, dy=0, name=None): 686 | return self.line_to(self.pos + Vec(dx, dy, 0), name=name) 687 | 688 | def close(self, keep=False): 689 | if self.begin is not None: 690 | begin = self.sketch.g_by_index(self.begin) 691 | g = self.line_to(begin.obj.StartPoint) 692 | if self.auto_coincident: 693 | self.sketch.c_coincident_end_start(g.index, self.begin) 694 | if not keep: 695 | self.begin = None 696 | return g 697 | 698 | def by_name(self, name): 699 | return self.sketch.g_by_name(name) 700 | 701 | def move_to(self, pnt): 702 | self.pos = to_vec(pnt) 703 | self.begin = None 704 | 705 | def move(self, dx=0, dy=0): 706 | self.pos = self.pos + to_vec((dx, dy)) 707 | self.begin = None 708 | 709 | def bspline_to(self, *pnts, name=None): 710 | pnt = to_vec(pnts[-1]) 711 | g = self.sketch.g_bspline([self.pos, *pnts]) 712 | self._concat(pnt, g, name) 713 | return g 714 | 715 | def bspline(self, *pnts, name=None): 716 | start = self.pos 717 | apnts = [] 718 | for pnt in pnts: 719 | start = start + pnt 720 | apnts.append(start) 721 | pnt = to_vec(apnts[-1]) 722 | g = self.sketch.g_bspline([self.pos, *apnts]) 723 | self._concat(pnt, g, name) 724 | return g 725 | 726 | def circle(self, radius, name=None): 727 | g = self.sketch.g_circle_center_radius(self.pos, radius) 728 | self._concat(self.pos, g, name) 729 | return g 730 | 731 | def point(self, name=None): 732 | g = self.sketch.g_point(self.pos) 733 | self.pos = to_vec(g.obj) 734 | if name: 735 | self.sketch.set_name(name, g) 736 | return g 737 | 738 | def circle_3points(self, p2, p3, name=None): 739 | g = self.sketch.g_circle_3points(self.pos, p2, p3) 740 | self._concat(to_vec(p3), g, name) 741 | return g 742 | 743 | def rect_to(self, pnt): 744 | orig = self.pos 745 | self.move_to(orig) 746 | g = self.line_to((orig[0], pnt[1])) 747 | self.sketch.c_vertical(g.index) 748 | g = self.line_to(pnt) 749 | self.sketch.c_horizontal(g.index) 750 | g = self.line_to((pnt[0], orig[1])) 751 | self.sketch.c_vertical(g.index) 752 | g = self.close() 753 | self.sketch.c_horizontal(g.index) 754 | return g 755 | 756 | def rect(self, w, h, angle=Quantity('0.0 deg')): 757 | if w <= 0: 758 | raise(RuntimeError(f'Invalid w={w} too small')) 759 | if h <= 0: 760 | raise(RuntimeError(f'Invalid h={h} too small')) 761 | orig = self.pos 762 | self.move_to(orig) 763 | g1 = self.line(dx=w) 764 | self.sketch.c_length(g1.index, w) 765 | g2 = self.line(dy=h) 766 | self.sketch.c_length(g2.index, h) 767 | self.sketch.c_angle(g1.index, GeomEnd, g2.index, GeomStart, Quantity('270 deg')) 768 | g3 = self.line(dx=-w) 769 | self.sketch.c_length(g3.index, w) 770 | self.sketch.c_angle(g2.index, GeomEnd, g3.index, GeomStart, Quantity('270 deg')) 771 | g4 = self.close() 772 | self.sketch.c_x_angle(g1.index, angle) 773 | self.sketch.c_xy(g1.index, orig[0], orig[1]) 774 | return g4 775 | 776 | def regular_polygon(self, param: any, edges: int, angle=None, constrain_pos: Optional[bool]=None, constrain_size: bool=True): 777 | orig = self.pos 778 | prev_index = len(self.sketch.obj.Geometry) - 1 779 | if isinstance(param, gq.Length): 780 | r = param.value / (2 * math.sin(math.pi/edges)) 781 | p2 = orig + Vec(0, r, 0) 782 | elif isinstance(param, gq.Radius): 783 | r = param.value 784 | p2 = orig + Vec(0, r, 0) 785 | elif isinstance(param, gq.Diameter): 786 | r = param.value/2.0 787 | p2 = orig + Vec(0, r, 0) 788 | elif isinstance(param, gq.DeltaVec): 789 | r = param.value.Length 790 | p2 = orig + param.value 791 | elif isinstance(param, (float,int)): # Implies Diameter 792 | r = param/2.0 793 | p2 = orig + Vec(0, r, 0) 794 | else: # Absolute point 795 | p2 = to_vec(param) 796 | r = (orig - p2).Length 797 | g = self.sketch.g_regular_polygon(orig, p2, edges) 798 | 799 | if constrain_pos is True: 800 | self.sketch.c_xy(g.index, orig[0], orig[1], anchor=GeomCenter) 801 | elif constrain_pos is None and self.auto_coincident and prev_index > -1 and self.begin: 802 | self.sketch.c_coincident_end_start(prev_index, g.index) 803 | 804 | if constrain_size: 805 | if isinstance(param, gq.Length): 806 | self.sketch.c_length(g.index-1, param.value) 807 | elif isinstance(param, gq.Radius): 808 | self.sketch.c_radius(g.index, param.value) 809 | elif isinstance(param, gq.Diameter): 810 | self.sketch.c_diameter(g.index, param.value) 811 | elif isinstance(param, gq.DeltaVec): 812 | self.sketch.c_radius(g.index, r) 813 | else: 814 | length = r * (2 * math.sin(math.pi/edges)) 815 | self.sketch.c_length(g.index-1, length) 816 | 817 | if angle is not None: 818 | with self.sketch.ref_mode(): 819 | seg = self.sketch.g_line(orig, p2) 820 | self.sketch.c_coincident(g.index, GeomCenter, seg.index, GeomStart) 821 | self.sketch.c_coincident(prev_index+1, GeomStart, seg.index, GeomEnd) 822 | self.sketch.c_y_angle(seg.index, angle) 823 | 824 | return g 825 | 826 | 827 | def line_with_length(self, dx=0, dy=0, ref=False, constraint_name=None): 828 | with self.sketch.ref_mode(ref): 829 | if dx == 0 and dy != 0: 830 | length = abs(dy) 831 | elif dx != 0 and dy == 0: 832 | length = abs(dx) 833 | elif dx != 0 and dy != 0: 834 | length = math.sqrt(dx*dx + dy*dy) 835 | else: 836 | raise(RuntimeError(f"Invalid (dx, dy) = ({dx}, {dy})")) 837 | seg = self.line(dx, dy) 838 | self.sketch.c_length(seg.index, length, name=constraint_name) 839 | return seg 840 | 841 | 842 | def rect_rounded(self, w, h, r, angle=Quantity('0.0 deg')): 843 | """ 844 | r: int | float => for all corners 845 | (rw, rh) => width and height radious 846 | (tl, tr, br, bl) => custom radius for each corner 847 | """ 848 | if w <= 0: 849 | raise(RuntimeError(f'Invalid w={w} too small')) 850 | if h <= 0: 851 | raise(RuntimeError(f'Invalid h={h} too small')) 852 | 853 | BL, BR, TR, TL = 0, 1, 2, 3 # Corners 854 | RW, RH = 0, 1 # Radiuses 855 | 856 | # transform r into ( (blw, blh), (brw, brh), (trw, trh), (tlw, tlh) ) 857 | if isinstance(r, (float, int)): 858 | r = ((r,r),)*4 859 | elif len(r) == 1: 860 | r = ((r,r),)*4 861 | elif len(r) == 2: 862 | r = ((r[0],r[1]),)*4 863 | elif len(r) == 4: 864 | r = ((r[0],r[0]),(r[1],r[1]),(r[2],r[2]),(r[3],r[3])) 865 | else: 866 | raise(RuntimeError(f'Invalid r={r}')) 867 | 868 | if any((x[RW] <= 0 or x[RH] <= 0 for x in r)): 869 | raise(RuntimeError(f'Invalid r={r} too small values')) 870 | 871 | if ( (r[BL][RW] + r[BR][RW] > w) 872 | or (r[TR][RW] + r[TL][RW] > w) 873 | or (r[BL][RH] + r[TL][RH] > h) 874 | or (r[BR][RH] + r[TR][RH] > h)): 875 | raise(RuntimeError(f'Invalid r={r} too large values')) 876 | 877 | orig = self.pos 878 | 879 | self.move_to(orig) 880 | 881 | angle180 = Quantity('180 deg') 882 | angle270 = Quantity('270 deg') 883 | 884 | # Bottom 885 | bl = self.line_with_length(dx=r[BL][RW], ref=True) 886 | bottom = self.line_with_length(dx=w - r[BL][RW] - r[BR][RW]) 887 | br = self.line_with_length(dx=r[BR][RW], ref=True) 888 | 889 | self.sketch.c_angle(bl.index, GeomEnd, bottom.index, GeomStart, angle180) 890 | self.sketch.c_angle(bottom.index, GeomEnd, br.index, GeomStart, angle180) 891 | 892 | # Right 893 | rb = self.line_with_length(dy=r[BR][RH], ref=True) 894 | right = self.line_with_length(dy=h - r[BR][RH] - r[TR][RH]) 895 | rt = self.line_with_length(dy=-r[TR][RH], ref=True) 896 | 897 | self.sketch.c_angle(br.index, GeomEnd, rb.index, GeomStart, angle270) 898 | self.sketch.c_angle(rb.index, GeomEnd, right.index, GeomStart, angle180) 899 | self.sketch.c_angle(right.index, GeomEnd, rt.index, GeomStart, angle180) 900 | 901 | # Top 902 | tr = self.line_with_length(dx=-r[TR][RW], ref=True) 903 | top = self.line_with_length(dx=-w + r[TR][RW] + r[TL][RW]) 904 | tl = self.line_with_length(dx=-r[TL][RW], ref=True) 905 | 906 | self.sketch.c_angle(rt.index, GeomEnd, tr.index, GeomStart, angle270) 907 | self.sketch.c_angle(tr.index, GeomEnd, top.index, GeomStart, angle180) 908 | self.sketch.c_angle(top.index, GeomEnd, tl.index, GeomStart, angle180) 909 | 910 | # Left 911 | lt = self.line_with_length(dy=-r[TL][RH], ref=True) 912 | left = self.line_with_length(dy=-h + r[BL][RH] + r[TL][RH]) 913 | lb = self.line_with_length(dy=r[BL][RH], ref=True) 914 | 915 | self.sketch.c_angle(tl.index, GeomEnd, lt.index, GeomStart, angle270) 916 | self.sketch.c_angle(lt.index, GeomEnd, left.index, GeomStart, angle180) 917 | self.sketch.c_angle(left.index, GeomEnd, lb.index, GeomStart, angle180) 918 | 919 | # Corner: BL 920 | self.move_to(left.obj.EndPoint) 921 | blc = self.bspline_to(lb.obj.EndPoint, bottom.obj.StartPoint) 922 | self.sketch.c_bspline_control_point(left.index, GeomEnd, blc.index, 0) 923 | self.sketch.c_bspline_control_point(lb.index, GeomEnd, blc.index, 1) 924 | self.sketch.c_bspline_control_point(bottom.index, GeomStart, blc.index, 2) 925 | 926 | # Corner: BR 927 | self.move_to(bottom.obj.EndPoint) 928 | blc = self.bspline_to(br.obj.EndPoint, rb.obj.StartPoint) 929 | self.sketch.c_bspline_control_point(bottom.index, GeomEnd, blc.index, 0) 930 | self.sketch.c_bspline_control_point(br.index, GeomEnd, blc.index, 1) 931 | self.sketch.c_bspline_control_point(right.index, GeomStart, blc.index, 2) 932 | 933 | # Corner: TR 934 | self.move_to(right.obj.EndPoint) 935 | blc = self.bspline_to(rt.obj.EndPoint, top.obj.StartPoint) 936 | self.sketch.c_bspline_control_point(right.index, GeomEnd, blc.index, 0) 937 | self.sketch.c_bspline_control_point(rt.index, GeomEnd, blc.index, 1) 938 | self.sketch.c_bspline_control_point(top.index, GeomStart, blc.index, 2) 939 | 940 | # Corner: TL 941 | self.move_to(top.obj.EndPoint) 942 | blc = self.bspline_to(tl.obj.EndPoint, left.obj.StartPoint) 943 | self.sketch.c_bspline_control_point(top.index, GeomEnd, blc.index, 0) 944 | self.sketch.c_bspline_control_point(tl.index, GeomEnd, blc.index, 1) 945 | self.sketch.c_bspline_control_point(left.index, GeomStart, blc.index, 2) 946 | 947 | # Pos 948 | self.sketch.c_xy(bl.index, orig[0], orig[1]) 949 | 950 | # Angle 951 | self.sketch.c_x_angle(bottom.index, angle) 952 | 953 | return blc 954 | 955 | 956 | # └────────────────────────────────────────────────────────────────────────────┘ 957 | # [SECTION] [PartDesign] Body 958 | # ┌────────────────────────────────────────────────────────────────────────────┐ 959 | 960 | 961 | class XBody: 962 | """PartDesign Body builder""" 963 | 964 | def __init__(self, name='XBody'): 965 | """Create or reuse a PartDesign Body""" 966 | self.obj = App.ActiveDocument.getObject(name) 967 | if not self.obj: 968 | self.obj = App.ActiveDocument.addObject('PartDesign::Body', name) 969 | else: 970 | if self.obj.TypeId != 'PartDesign::Body': 971 | raise RuntimeError(f'The named object "{name}" already exists but it is not a PartDesign::Body') 972 | 973 | 974 | @property 975 | def name(self): 976 | return self.obj.Name 977 | 978 | 979 | def sketch(self, name:str='XSketch', plane:str='XY', reversed:bool=False, pos:Vec=Vec(0,0,0), rot:Rotation=Rotation(0,0,0)): 980 | """Sketch builder.""" 981 | support = find_obj_origin_plane(self.obj, plane) 982 | return self.sketch_on([(support, '')], name=name, reversed=reversed, pos=pos, rot=rot) 983 | 984 | 985 | def sketch_on(self, support, mode='FlatFace', parameter=0.0, name:str='XSketch', reversed:bool=False, pos:Vec=Vec(0,0,0), rot:Rotation=Rotation(0,0,0)): 986 | """Sketch builder.""" 987 | xsketch = XSketch(name, parent=self.obj) 988 | sketch = xsketch.obj 989 | sketch.AttachmentOffset = App.Placement(pos, rot) 990 | sketch.MapReversed = reversed 991 | sketch.Support = support 992 | sketch.MapPathParameter = parameter 993 | sketch.MapMode = mode 994 | return xsketch 995 | 996 | 997 | def fillet(self, edges: list, name='Fillet'): 998 | name = f"{self.name}_{name}" 999 | fillet = App.ActiveDocument.getObject(name) 1000 | if not fillet: 1001 | fillet = App.ActiveDocument.addObject('Part::Fillet', name) 1002 | fillet.Base = self.obj 1003 | fillet.Edges = [*edges] 1004 | Gui.ActiveDocument.getObject(self.name).Visibility = False 1005 | 1006 | 1007 | # └────────────────────────────────────────────────────────────────────────────┘ 1008 | # [SECTION] [FeaturePython] Data Object 1009 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1010 | 1011 | class DataObject: 1012 | """Document Object with properties""" 1013 | 1014 | def __init__(self, name="Data"): 1015 | obj = App.ActiveDocument.getObject(name) 1016 | if not obj: 1017 | obj = App.ActiveDocument.addObject('App::FeaturePython', name) 1018 | else: 1019 | if obj.TypeId != 'App::FeaturePython': 1020 | raise RuntimeError(f'The named object "{name}" already exists but it is not of type App::FeaturePython') 1021 | super().__setattr__('obj', obj) 1022 | 1023 | def add_property(self, property_type, name, section="Properties", docs="", mode=0): 1024 | try: 1025 | self.obj.addProperty(property_type, name, section, docs, mode) 1026 | except: 1027 | pass # Ignore if already exists 1028 | 1029 | def __setattr__(self, __name: str, __value: Any) -> None: 1030 | if hasattr(self, __name): 1031 | super().__setattr__(__name, __value) 1032 | else: 1033 | if isinstance(__value, Expr): 1034 | self.obj.__setattr__(__name, __value(self.obj)) 1035 | __value.set_to(self.obj, __name) 1036 | else: 1037 | self.obj.setExpression(__name, None) 1038 | self.obj.__setattr__(__name, __value) 1039 | 1040 | def __getattr__(self, __name: str) -> Any: 1041 | return self.obj.__getattr(__name) 1042 | 1043 | 1044 | # └────────────────────────────────────────────────────────────────────────────┘ 1045 | # [SECTION] [Wire] Free Wire in 3D 1046 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1047 | 1048 | class Wire3D: 1049 | def __init__(self, origin=(0,0,0)): 1050 | self.origin = to_vec(origin) 1051 | self.pos = self.origin 1052 | self.edges = [] 1053 | 1054 | def add_bspline(self, poles): 1055 | vec_poles = [to_vec(p) for p in poles] 1056 | bspline = Part.BSplineCurve([self.pos, *vec_poles]) 1057 | self.edges.append(Part.Edge(bspline)) 1058 | self.pos = vec_poles[-1] 1059 | 1060 | def add_segment(self, point): 1061 | point = to_vec(point) 1062 | if self.pos != point: 1063 | segment = Part.Edge(Part.LineSegment(self.pos, point)) 1064 | self.edges.append(segment) 1065 | self.pos = point 1066 | 1067 | def add_round_corner(self, point, radius, tangency=0.618033): 1068 | point = to_vec(point) 1069 | tangency = clamp(tangency, 0.2, 0.8) 1070 | point_dist = radius * tangency 1071 | point_dist2 = radius - point_dist 1072 | tangent1 = self.tangent * point_dist 1073 | p0 = self.pos 1074 | p1 = self.pos + tangent1 1075 | tangent1 = self.tangent * point_dist2 1076 | p2 = p1 + tangent1 1077 | v_tangent2 = dir_of_2points(p2, point) 1078 | tangent2 = v_tangent2 * point_dist2 1079 | p3 = p2 + tangent2 1080 | tangent2 = v_tangent2 * point_dist 1081 | p4 = p3 + tangent2 1082 | self.add_bspline((p1, p3, p4)) 1083 | self.add_segment(point) 1084 | 1085 | def build(self): 1086 | return Part.Wire(self.edges) 1087 | 1088 | def set_to(self, name): 1089 | obj = App.ActiveDocument.getObject(name) 1090 | if obj: 1091 | obj.Shape = self.build() 1092 | else: 1093 | Part.show(self.build(), name) 1094 | 1095 | @property 1096 | def tangent(self): 1097 | last_edge = self.edges[-1] 1098 | return last_edge.Curve.tangent(last_edge.LastParameter)[0].normalize() 1099 | 1100 | 1101 | # └────────────────────────────────────────────────────────────────────────────┘ 1102 | # [SECTION] [GUI] Imports 1103 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1104 | 1105 | from PySide import QtCore, QtGui 1106 | 1107 | # └────────────────────────────────────────────────────────────────────────────┘ 1108 | # [SECTION] [GUI] Aliases 1109 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1110 | 1111 | Icon = QtGui.QIcon 1112 | Image = QtGui.QPixmap 1113 | 1114 | # └────────────────────────────────────────────────────────────────────────────┘ 1115 | # [SECTION] [GUI] Globals 1116 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1117 | 1118 | ThreadLocalGuiVars = threading.local() # Store GUI State per thread 1119 | 1120 | 1121 | # └────────────────────────────────────────────────────────────────────────────┘ 1122 | # [SECTION] [GUI] Utils 1123 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1124 | 1125 | def set_qt_attrs(qt_object, **kwargs): 1126 | """Call setters on QT objects by argument names.""" 1127 | for name, value in kwargs.items(): 1128 | if value is not None: 1129 | setter = getattr(qt_object, f'set{name[0].upper()}{name[1:]}', None) 1130 | if setter: 1131 | if isinstance(value, tuple): 1132 | setter(*value) 1133 | else: 1134 | setter(value) 1135 | 1136 | 1137 | def setup_layout(layout, add=True, **kwargs): 1138 | """Setup layouts adding wrapper widget if required.""" 1139 | set_qt_attrs(layout, **kwargs) 1140 | parent = build_context().current() 1141 | if parent.layout() is not None or add is False: 1142 | w = QtGui.QWidget() 1143 | w.setLayout(layout) 1144 | if add: 1145 | parent.layout().addWidget(w) 1146 | with build_context().stack(w): 1147 | yield w 1148 | else: 1149 | parent.setLayout(layout) 1150 | yield parent 1151 | 1152 | 1153 | def place_widget(ed, label=None, stretch=0, alignment=QtCore.Qt.Alignment()): 1154 | """Place widget in layout.""" 1155 | layout = build_context().current().layout() 1156 | if layout is None: 1157 | layout = QtGui.QVBoxLayout() 1158 | build_context().current().setLayout(layout) 1159 | if label is None: 1160 | build_context().current().layout().addWidget(ed, stretch, alignment) 1161 | else: 1162 | w = QtGui.QWidget() 1163 | parent = QtGui.QHBoxLayout() 1164 | parent.addWidget(QtGui.QLabel(label)) 1165 | parent.addWidget(ed) 1166 | w.setLayout(parent) 1167 | build_context().current().layout().addWidget(w, stretch, alignment) 1168 | 1169 | 1170 | class PySignal: 1171 | """Imitate Qt Signals for non QObject objects""" 1172 | 1173 | def __init__(self): 1174 | self._listeners = [] 1175 | 1176 | def connect(self, listener): 1177 | self._listeners.append(listener) 1178 | 1179 | def trigger(self, *args, **kwargs): 1180 | for listener in self._listeners: 1181 | listener(*args, **kwargs) 1182 | 1183 | 1184 | #: Decorator 1185 | def on_event(target, event=None): 1186 | """Decorator: Event binder""" 1187 | if event is None: 1188 | def deco(fn): 1189 | target.connect(fn) 1190 | return fn 1191 | else: 1192 | def deco(fn): 1193 | getattr(target, event).connect(fn) 1194 | return fn 1195 | return deco 1196 | 1197 | 1198 | # └────────────────────────────────────────────────────────────────────────────┘ 1199 | # [SECTION] [GUI] Selection 1200 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1201 | 1202 | class SelectedObject: 1203 | """Store Selection information""" 1204 | 1205 | def __init__(self, doc, obj, sub=None, pnt=None): 1206 | self.doc = doc 1207 | self.obj = obj 1208 | self.sub = sub 1209 | self.pnt = pnt 1210 | 1211 | def __iter__(self): 1212 | yield App.getDocument(self.doc).getObject(self.obj) 1213 | yield self.sub 1214 | yield self.pnt 1215 | 1216 | def __repr__(self) -> str: 1217 | return f"{self.doc}#{self.obj}.{self.sub}" 1218 | 1219 | def __hash__(self) -> int: 1220 | return hash((self.doc, self.obj, self.sub)) 1221 | 1222 | def __eq__(self, __o: object) -> bool: 1223 | return hash(self) == hash(__o) 1224 | 1225 | def __ne__(self, __o: object) -> bool: 1226 | return not self.__eq__(__o) 1227 | 1228 | 1229 | def register_select_observer(owner: QtGui.QWidget, observer): 1230 | """Add observer with auto remove on owner destroyed""" 1231 | Gui.Selection.addObserver(observer) 1232 | def destroyed(_): 1233 | Gui.Selection.removeObserver(observer) 1234 | owner.destroyed.connect(destroyed) 1235 | 1236 | 1237 | @contextmanager 1238 | def selection(*names, clean=True): 1239 | sel = Gui.Selection 1240 | try: 1241 | doc = App.ActiveDocument.Name 1242 | if len(names) == 0: 1243 | yield sel.getSelection(doc) 1244 | else: 1245 | sel.clearSelection() 1246 | for name in names: 1247 | if isinstance(name, (tuple, list)): 1248 | sel.addSelection(doc, *name) 1249 | elif isinstance(name, SelectedObject): 1250 | sel.addSelection(name.doc, name.obj, name.sub) 1251 | else: 1252 | sel.addSelection(doc, name) 1253 | yield sel.getSelection(doc) 1254 | finally: 1255 | if clean: 1256 | sel.clearSelection() 1257 | 1258 | 1259 | # └────────────────────────────────────────────────────────────────────────────┘ 1260 | # [SECTION] [GUI] Build Context 1261 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1262 | 1263 | def build_context(): 1264 | bc = getattr(ThreadLocalGuiVars, 'BuildContext', None) 1265 | if bc is None: 1266 | ThreadLocalGuiVars.BuildContext = _BuildContext() 1267 | return ThreadLocalGuiVars.BuildContext 1268 | else: 1269 | return bc 1270 | 1271 | class _BuildContext: 1272 | def __init__(self): 1273 | self._stack = [] 1274 | 1275 | def push(self, widget): 1276 | self._stack.append(widget) 1277 | 1278 | def pop(self): 1279 | self._stack.pop() 1280 | 1281 | @contextmanager 1282 | def stack(self, widget): 1283 | self.push(widget) 1284 | try: 1285 | yield widget 1286 | finally: 1287 | self.pop() 1288 | 1289 | @contextmanager 1290 | def parent(self): 1291 | if len(self._stack) > 1: 1292 | current = self._stack[-1] 1293 | self._stack.pop() 1294 | parent = self._stack[-1] 1295 | try: 1296 | yield parent 1297 | finally: 1298 | self._stack.append(current) 1299 | 1300 | def current(self): 1301 | return self._stack[-1] 1302 | 1303 | def dump(self): 1304 | print(f"BuildContext: {self._stack}") 1305 | 1306 | @contextmanager 1307 | def Parent(): 1308 | """Put parent on top of BuildContext""" 1309 | with build_context().parent() as p: 1310 | yield p 1311 | 1312 | 1313 | # └────────────────────────────────────────────────────────────────────────────┘ 1314 | # [SECTION] [GUI] [Widget] Dialog 1315 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1316 | 1317 | class Dialogs: 1318 | _list = [] 1319 | 1320 | @classmethod 1321 | def dump(cls): 1322 | print(f"Dialogs: {cls._list}") 1323 | 1324 | @classmethod 1325 | def register(cls, dialog): 1326 | cls._list.append(dialog) 1327 | dialog.closeEvent = lambda e: cls.destroy_dialog(dialog) 1328 | 1329 | @classmethod 1330 | def destroy_dialog(cls, dlg): 1331 | cls._list.remove(dlg) 1332 | dlg.deleteLater() 1333 | 1334 | 1335 | @contextmanager 1336 | def Dialog(title=None, size=None, show=True, parent=None): 1337 | if parent is None: 1338 | w = QtGui.QDialog(parent=Gui.getMainWindow()) 1339 | else: 1340 | w = QtGui.QWidget(parent=parent) 1341 | if title is not None: 1342 | w.setWindowTitle(title) 1343 | with build_context().stack(w): 1344 | yield w 1345 | if show: 1346 | Dialogs.register(w) 1347 | w.show() 1348 | if isinstance(size, (tuple,list)): 1349 | w.resize(size[0], size[1]) 1350 | else: 1351 | w.adjustSize() 1352 | 1353 | 1354 | # └────────────────────────────────────────────────────────────────────────────┘ 1355 | # [SECTION] [GUI] [Widget] GroupBox 1356 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1357 | 1358 | @contextmanager 1359 | def GroupBox(title=None): 1360 | w = QtGui.QGroupBox() 1361 | if title: 1362 | w.setTitle(title) 1363 | place_widget(w) 1364 | with build_context().stack(w): 1365 | yield w 1366 | 1367 | 1368 | # └────────────────────────────────────────────────────────────────────────────┘ 1369 | # [SECTION] [GUI] [Widget] Stretch 1370 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1371 | 1372 | def Stretch(stretch=0): 1373 | """Add Layout spacer""" 1374 | layout = build_context().current().layout() 1375 | if layout: 1376 | layout.addStretch(stretch) 1377 | 1378 | 1379 | # └────────────────────────────────────────────────────────────────────────────┘ 1380 | # [SECTION] [GUI] [Widget] TabContainer 1381 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1382 | 1383 | @contextmanager 1384 | def TabContainer(**kwargs): 1385 | w = QtGui.QTabWidget() 1386 | set_qt_attrs(w, **kwargs) 1387 | place_widget(w) 1388 | with build_context().stack(w): 1389 | yield w 1390 | 1391 | 1392 | # └────────────────────────────────────────────────────────────────────────────┘ 1393 | # [SECTION] [GUI] [Widget] Tab 1394 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1395 | 1396 | @contextmanager 1397 | def Tab(title:str, icon=None): 1398 | w = QtGui.QWidget() 1399 | with build_context().stack(w): 1400 | yield w 1401 | if icon: 1402 | build_context().current().addTab(w, title, icon) 1403 | else: 1404 | build_context().current().addTab(w, title) 1405 | 1406 | 1407 | # └────────────────────────────────────────────────────────────────────────────┘ 1408 | # [SECTION] [GUI] [Layout] Col 1409 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1410 | 1411 | @contextmanager 1412 | def Col(add=True, **kwargs): 1413 | """Vertical Layout""" 1414 | yield from setup_layout(QtGui.QVBoxLayout(), add=add, **kwargs) 1415 | 1416 | 1417 | # └────────────────────────────────────────────────────────────────────────────┘ 1418 | # [SECTION] [GUI] [Layout] Row 1419 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1420 | 1421 | @contextmanager 1422 | def Row(add=True, **kwargs): 1423 | """Horizontal Layout""" 1424 | yield from setup_layout(QtGui.QHBoxLayout(), add=add, **kwargs) 1425 | 1426 | 1427 | # └────────────────────────────────────────────────────────────────────────────┘ 1428 | # [SECTION] [GUI] [Widget] TextLabel 1429 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1430 | 1431 | def TextLabel(text="", stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1432 | label = QtGui.QLabel(text) 1433 | set_qt_attrs(label, **kwargs) 1434 | place_widget(label, stretch=stretch, alignment=alignment) 1435 | return label 1436 | 1437 | 1438 | # └────────────────────────────────────────────────────────────────────────────┘ 1439 | # [SECTION] [GUI] [Widget] InputFloat 1440 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1441 | 1442 | def InputFloat(name=None, min=0.0, max=sys.float_info.max, decimals=6, 1443 | step=0.01, label=None, value=0.0, stretch=0, 1444 | alignment=QtCore.Qt.Alignment(), **kwargs): 1445 | editor = QtGui.QDoubleSpinBox() 1446 | editor.setMinimum(min) 1447 | editor.setMaximum(max) 1448 | editor.setSingleStep(step) 1449 | editor.setDecimals(decimals) 1450 | editor.setValue(value) 1451 | set_qt_attrs(editor, **kwargs) 1452 | if name: 1453 | editor.setObjectName(name) 1454 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1455 | return editor 1456 | 1457 | 1458 | # └────────────────────────────────────────────────────────────────────────────┘ 1459 | # [SECTION] [GUI] [Widget] InputText 1460 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1461 | 1462 | class InputTextWidget(QtGui.QLineEdit): 1463 | def __init__(self, *args, **kwargs): 1464 | super().__init__(*args, **kwargs) 1465 | def value(self): 1466 | return self.text() 1467 | def setValue(self, value): 1468 | self.setText(str(value)) 1469 | 1470 | def InputText(name=None, label=None, value="", 1471 | stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1472 | editor = InputTextWidget() 1473 | editor.setText(value) 1474 | set_qt_attrs(editor, **kwargs) 1475 | if name: 1476 | editor.setObjectName(name) 1477 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1478 | return editor 1479 | 1480 | 1481 | # └────────────────────────────────────────────────────────────────────────────┘ 1482 | # [SECTION] [GUI] [Widget] InputInt 1483 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1484 | 1485 | def InputInt(name=None, min=0, max=2^31, step=1, label=None, value=0, 1486 | stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1487 | editor = QtGui.QSpinBox() 1488 | editor.setMinimum(min) 1489 | editor.setMaximum(max) 1490 | editor.setSingleStep(step) 1491 | editor.setValue(value) 1492 | set_qt_attrs(editor, **kwargs) 1493 | if name: 1494 | editor.setObjectName(name) 1495 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1496 | return editor 1497 | 1498 | 1499 | # └────────────────────────────────────────────────────────────────────────────┘ 1500 | # [SECTION] [GUI] [Widget] InputBoolean 1501 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1502 | 1503 | class QCheckBoxExt(QtGui.QCheckBox): 1504 | def __init__(self, *args, **kwargs): 1505 | super().__init__(*args, **kwargs) 1506 | 1507 | def value(self) -> bool: 1508 | return self.checkState() == QtCore.Qt.Checked 1509 | 1510 | def setValue(self, value : bool): 1511 | self.setCheckState(QtCore.Qt.Checked if value else QtCore.Qt.Unchecked) 1512 | 1513 | 1514 | def InputBoolean(name=None, label=None, value=False, stretch=0, 1515 | alignment=QtCore.Qt.Alignment(), **kwargs): 1516 | editor = QCheckBoxExt() 1517 | editor.setValue(value) 1518 | set_qt_attrs(editor, **kwargs) 1519 | if name: 1520 | editor.setObjectName(name) 1521 | place_widget(editor, label=label, stretch=stretch, alignment=alignment) 1522 | return editor 1523 | 1524 | # └────────────────────────────────────────────────────────────────────────────┘ 1525 | # [SECTION] [GUI] [Widget] InputVector 1526 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1527 | 1528 | class InputVectorWrapper: 1529 | def __init__(self, g, x, y, z): 1530 | self.group = g 1531 | self.x = x 1532 | self.y = y 1533 | self.z = z 1534 | 1535 | def value(self) -> Vec: 1536 | return Vec(self.x.value(), self.y.value(), self.z.value()) 1537 | 1538 | def setValue(self, value): 1539 | v = to_vec(value) 1540 | self.x.setValue(v.x) 1541 | self.y.setValue(v.y) 1542 | self.z.setValue(v.z) 1543 | 1544 | def InputVector(label=None, value=(0.0,0.0,0.0)): 1545 | with GroupBox(title=label) as g: 1546 | with Col(): 1547 | x = InputFloat(label="X:") 1548 | y = InputFloat(label="Y:") 1549 | z = InputFloat(label="Z:") 1550 | widget = InputVectorWrapper(g, x, y, z) 1551 | widget.setValue(value) 1552 | return widget 1553 | 1554 | 1555 | # └────────────────────────────────────────────────────────────────────────────┘ 1556 | # [SECTION] [GUI] [Widget] InputOptions 1557 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1558 | 1559 | class InputOptionsWrapper: 1560 | def __init__(self, combobox:QtGui.QComboBox, data: Dict[str,Any]): 1561 | self.combobox = combobox 1562 | self.index = dict() 1563 | self.lookup = dict() 1564 | i = 0 1565 | for label, value in data.items(): 1566 | self.index[i] = value 1567 | self.lookup[value] = i 1568 | i += 1 1569 | combobox.addItem(label) 1570 | 1571 | def value(self): 1572 | return self.index.get(self.combobox.currentIndex(), None) 1573 | 1574 | def setValue(self, value): 1575 | index = self.lookup.get(value, None) 1576 | if index is not None: 1577 | self.combobox.setCurrentIndex(index) 1578 | 1579 | def InputOptions(options, value=None, label=None, name=None, stretch=0, 1580 | alignment=QtCore.Qt.Alignment(), **kwargs): 1581 | widget = QtGui.QComboBox() 1582 | set_qt_attrs(widget, **kwargs) 1583 | editor = InputOptionsWrapper(widget, options) 1584 | if value is not None: 1585 | editor.setValue(value) 1586 | if name: 1587 | editor.combobox.setObjectName(name) 1588 | place_widget(editor.combobox, label=label, stretch=stretch, alignment=alignment) 1589 | return editor 1590 | 1591 | 1592 | # └────────────────────────────────────────────────────────────────────────────┘ 1593 | # [SECTION] [GUI] [Widget] InputSelectOne 1594 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1595 | 1596 | class InputSelectOne: 1597 | 1598 | def __init__(self, label=None, name=None, active=False, auto_deactivate=True): 1599 | self._value = None 1600 | self._pre = None 1601 | self._auto_deactivate = auto_deactivate 1602 | self.selected = PySignal() 1603 | with Row(add=False, spacing=0, margin=0, contentsMargins=(0,0,0,0)) as ctl: 1604 | 1605 | @button( 1606 | text="Select...", 1607 | tool=True, 1608 | checkable=True, 1609 | styleSheet="QToolButton:checked{background-color: #FF0000; color:#FFFFFF;}", 1610 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1611 | objectName=name, 1612 | checked=active) 1613 | def select(): pass 1614 | 1615 | @button( 1616 | tool=True, 1617 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1618 | icon=Icon(':icons/edit-cleartext.svg')) 1619 | def clear(): self.setValue(None) 1620 | 1621 | display = QtGui.QLineEdit() 1622 | display.setReadOnly(True) 1623 | place_widget(display) 1624 | 1625 | self.display = display 1626 | self.button = select 1627 | register_select_observer(select, self) 1628 | 1629 | with Parent(): 1630 | place_widget(ctl, label=label) 1631 | 1632 | @property 1633 | def active(self) -> bool: 1634 | return self.button.isChecked() 1635 | 1636 | def value(self) -> Optional[SelectedObject]: 1637 | return self._value 1638 | 1639 | def pre(self) -> Optional[SelectedObject]: 1640 | return self._pre 1641 | 1642 | def setValue(self, value: Optional[SelectedObject]) -> None: 1643 | self._value = value 1644 | if value: 1645 | self.display.setText(f"{value.doc}#{value.obj}.{value.sub}") 1646 | if self._auto_deactivate: 1647 | self.button.setChecked(False) 1648 | self.selected.trigger(self._value) 1649 | else: 1650 | self.display.setText(f"") 1651 | 1652 | def setPreselection(self, doc, obj, sub): 1653 | if self.button.isChecked(): 1654 | self._pre = SelectedObject(doc, obj, sub) 1655 | 1656 | def addSelection(self, doc, obj, sub, pnt): 1657 | if self.button.isChecked(): 1658 | self.setValue(SelectedObject(doc, obj, sub, pnt)) 1659 | 1660 | def removeSelection(self, doc, obj, sub): 1661 | if self.button.isChecked(): 1662 | if self._value: 1663 | v = self._value 1664 | if (v.doc, v.obj) == (doc, obj): 1665 | self.setValue(None) 1666 | 1667 | def setSelection(self, doc): 1668 | if self.button.isChecked(): 1669 | self.setValue(SelectedObject(doc, Gui.Selection.getSelection()[-1].Name)) 1670 | 1671 | def clearSelection(self, doc): 1672 | pass 1673 | 1674 | 1675 | # └────────────────────────────────────────────────────────────────────────────┘ 1676 | # [SECTION] [GUI] [Widget] InputSelectMany 1677 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1678 | 1679 | class InputSelectMany: 1680 | 1681 | ValueDataRole = QtCore.Qt.UserRole 1682 | 1683 | def __init__(self, label=None, name=None, active=False): 1684 | self._value = set() 1685 | self.selected = PySignal() 1686 | with Col(add=False, spacing=0, margin=0, contentsMargins=(0,0,0,0)) as ctl: 1687 | with Row(spacing=0, margin=0, contentsMargins=(0,0,0,0)): 1688 | @button( 1689 | text="Add", 1690 | alignment=QtCore.Qt.AlignLeft, 1691 | tool=True, 1692 | checkable=True, 1693 | styleSheet="QToolButton:checked{background-color: #FF0000; color:#FFFFFF;}", 1694 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1695 | objectName=name, 1696 | checked=active) 1697 | def select(): pass 1698 | 1699 | @button( 1700 | text="Remove", 1701 | tool=True, 1702 | alignment=QtCore.Qt.AlignLeft, 1703 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus) 1704 | def remove(): 1705 | selected = self.display.selectedItems() 1706 | for item in selected: 1707 | value = item.data(0, InputSelectMany.ValueDataRole) 1708 | self._value.remove(value) 1709 | self.display.takeTopLevelItem(self.display.indexOfTopLevelItem(item)) 1710 | 1711 | @button( 1712 | text="Clean", 1713 | tool=True, 1714 | alignment=QtCore.Qt.AlignLeft, 1715 | focusPolicy=QtCore.Qt.FocusPolicy.NoFocus, 1716 | icon=Icon(':icons/edit-cleartext.svg')) 1717 | def clear(): 1718 | self._value.clear() 1719 | self.display.clear() 1720 | 1721 | Stretch() 1722 | 1723 | display = QtGui.QTreeWidget() 1724 | display.setColumnCount(2) 1725 | display.setHeaderLabels(['Object', 'SubObject']) 1726 | place_widget(display) 1727 | 1728 | self.display = display 1729 | self.button = select 1730 | register_select_observer(select, self) 1731 | 1732 | with Parent(): 1733 | with GroupBox(title=label): 1734 | place_widget(ctl) 1735 | 1736 | @property 1737 | def active(self) -> bool: 1738 | return self.button.isChecked() 1739 | 1740 | def value(self) -> List[SelectedObject]: 1741 | return self._value 1742 | 1743 | def addValue(self, value: SelectedObject) -> None: 1744 | if value not in self._value: 1745 | item = QtGui.QTreeWidgetItem([value.obj, value.sub]) 1746 | item.setData(0, InputSelectMany.ValueDataRole, value) 1747 | self.display.addTopLevelItem(item) 1748 | self._value.add(value) 1749 | self.selected.trigger(value) 1750 | 1751 | def setPreselection(self, doc, obj, sub): 1752 | pass 1753 | 1754 | def addSelection(self, doc, obj, sub, pnt): 1755 | if self.button.isChecked(): 1756 | self.addValue(SelectedObject(doc, obj, sub, pnt)) 1757 | 1758 | def removeSelection(self, doc, obj, sub): 1759 | pass 1760 | 1761 | def setSelection(self, doc): 1762 | if self.button.isChecked(): 1763 | self.addValue(SelectedObject(doc, Gui.Selection.getSelection()[-1].Name)) 1764 | 1765 | def clearSelection(self, doc): 1766 | pass 1767 | 1768 | 1769 | # └────────────────────────────────────────────────────────────────────────────┘ 1770 | # [SECTION] [GUI] [Widget] button 1771 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1772 | 1773 | def button(label=None, add:bool=True, tool:bool=False, stretch=0, alignment=QtCore.Qt.Alignment(), **kwargs): 1774 | if tool: 1775 | btn = QtGui.QToolButton() 1776 | else: 1777 | btn = QtGui.QPushButton() 1778 | set_qt_attrs(btn, **kwargs) 1779 | if label: 1780 | btn.setText(label) 1781 | elif 'text' not in kwargs: 1782 | btn.setText("Button") 1783 | if add: 1784 | place_widget(btn, stretch=stretch, alignment=alignment) 1785 | def wrapper(handler): 1786 | btn.clicked.connect(handler) 1787 | return btn 1788 | return wrapper 1789 | 1790 | 1791 | # └────────────────────────────────────────────────────────────────────────────┘ 1792 | # [SECTION] [GUI] progress_indicator 1793 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1794 | 1795 | class ProgressIndicator: 1796 | def __init__(self, *args, **kwargs) -> None: 1797 | try: 1798 | self.control = Base.ProgressIndicator(*args, **kwargs) 1799 | except: 1800 | self.control = None 1801 | def start(self, *args, **kwargs): 1802 | if self.control: 1803 | self.control.start(*args, **kwargs) 1804 | def next(self, *args, **kwargs): 1805 | if self.control: 1806 | self.control.next(*args, **kwargs) 1807 | def stop(self, *args, **kwargs): 1808 | if self.control: 1809 | self.control.stop(*args, **kwargs) 1810 | 1811 | @contextmanager 1812 | def progress_indicator(message: str = "Working...", steps: int = 0): 1813 | bar = ProgressIndicator() 1814 | bar.start(message, steps) 1815 | try: 1816 | yield bar 1817 | finally: 1818 | bar.stop() 1819 | del bar 1820 | 1821 | 1822 | # └────────────────────────────────────────────────────────────────────────────┘ 1823 | # [SECTION] [GUI] State 1824 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1825 | 1826 | def qt_get_widget_path(widget, index): 1827 | name = widget.objectName() 1828 | if not name: 1829 | name = f"{widget.__class__.__name__}_{index}" 1830 | path = [name] 1831 | parent = widget.parent() 1832 | while parent: 1833 | path.append(parent.objectName() or parent.__class__.__name__) 1834 | parent = parent.parent() 1835 | return "/".join(reversed(path)) 1836 | 1837 | 1838 | def save_widget_state(widget, name): 1839 | root = get_macros_dir() 1840 | data = {} 1841 | index = 0 1842 | for child in widget.findChildren(QtGui.QWidget): 1843 | if hasattr(child, 'value'): 1844 | path = qt_get_widget_path(child, index) 1845 | index += 1 1846 | try: 1847 | data[path] = child.value() 1848 | except: 1849 | print(f"Ignoring value of {path}") 1850 | with open(Path(root, f"fcscript_{name}.json"), 'w') as f: 1851 | f.write(json.dumps(data)) 1852 | 1853 | 1854 | def load_widget_state(widget, name): 1855 | file = Path(get_macros_dir(), f"fcscript_{name}.json") 1856 | if file.exists(): 1857 | with open(file, 'r') as f: 1858 | data = json.load(f) 1859 | index = 0 1860 | for child in widget.findChildren(QtGui.QWidget): 1861 | if hasattr(child, 'value'): 1862 | path = qt_get_widget_path(child, index) 1863 | index += 1 1864 | if path in data: 1865 | try: 1866 | child.setValue(data[path]) 1867 | except: 1868 | print(f"Ignoring value of {path} because it was not found") 1869 | 1870 | 1871 | # └────────────────────────────────────────────────────────────────────────────┘ 1872 | # [SECTION] [GUI] Message Boxes 1873 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1874 | 1875 | def show_msgbox(message, title="Information", std_icon=QtGui.QMessageBox.Information, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1876 | diag = QtGui.QMessageBox(std_icon, title, message, std_buttons, parent) 1877 | diag.setWindowModality(QtCore.Qt.ApplicationModal) 1878 | diag.exec_() 1879 | 1880 | 1881 | def show_warning(message, title="Warning", std_icon=QtGui.QMessageBox.Warning, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1882 | show_msgbox(message, title, std_icon, std_buttons, parent) 1883 | 1884 | 1885 | def show_error(message, title="Error", std_icon=QtGui.QMessageBox.Critical, std_buttons=QtGui.QMessageBox.NoButton, parent=None): 1886 | show_msgbox(message, title, std_icon, std_buttons, parent) 1887 | 1888 | 1889 | show_info = show_msgbox 1890 | 1891 | 1892 | # └────────────────────────────────────────────────────────────────────────────┘ 1893 | # [SECTION] [TEST] 1894 | # ┌────────────────────────────────────────────────────────────────────────────┐ 1895 | 1896 | 1897 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | FCScript 4 | DSL For Macros 5 | 0.0.1 6 | Frank Martinez 7 | GPL-3.0 8 | https://github.com/mnesarco/fcscript 9 | https://github.com/mnesarco/fcscript/wiki 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnesarco/fcscript/bbdf566249578f27e95845cef9b752900e88ddf9/screenshot.jpg --------------------------------------------------------------------------------