diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..a76a6c30 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: gumyr diff --git a/.gitignore b/.gitignore index a31af4dc..5eb8ee10 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ docs/_build/ *.STEP *.stl *.svg +*.dxf #mypy cache .mypy_cache diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/advanced.rst b/docs/advanced.rst index 25f776d4..8f171bfc 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -5,10 +5,8 @@ Advanced Topics .. toctree:: :maxdepth: 2 - assemblies.rst algebra_performance.rst location_arithmetic.rst algebra_definition.rst center.rst - custom.rst debugging_logging.rst diff --git a/docs/algebra_definition.rst b/docs/algebra_definition.rst index 941f2dce..05167934 100644 --- a/docs/algebra_definition.rst +++ b/docs/algebra_definition.rst @@ -29,7 +29,7 @@ Objects and arithmetic :math:`B^2 := \lbrace` ``Sketch``, ``Rectangle``, ``Circle``, ``Ellipse``, ``Rectangle``, ``Polygon``, ``RegularPolygon``, ``Text``, ``Trapezoid``, ``SlotArc``, ``SlotCenterPoint``, ``SlotCenterToCenter``, ``SlotOverall`` :math:`\rbrace` -:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` +:math:`B^1 := \lbrace` ``Curve``, ``Bezier``, ``FilletPolyline``, ``PolarLine``, ``Polyline``, ``Spline``, ``Helix``, ``CenterArc``, ``EllipticalCenterArc``, ``RadiusArc``, ``SagittaArc``, ``TangentArc``, ``ThreePointArc``, ``JernArc`` :math:`\rbrace` with :math:`B^3 \subset C^3, B^2 \subset C^2` and :math:`B^1 \subset C^1` @@ -84,7 +84,7 @@ Neutral element: :math:`\; l_0 \in L`: ``Location()`` :math:`*: P \times L \rightarrow P` with :math:`(p,l) \mapsto p * l` - :math:`\; p * l :=` ``Plane(p.to_location() * l)`` (move plane :math:`p \in P` to location :math:`l \in L`) + :math:`\; p * l :=` ``Plane(p.location * l)`` (move plane :math:`p \in P` to location :math:`l \in L`) Inverse element: :math:`\; l^{-1} \in L`: ``l.inverse()`` @@ -93,7 +93,7 @@ Inverse element: :math:`\; l^{-1} \in L`: ``l.inverse()`` :math:`*: P \times C^n \rightarrow C^n \;` with :math:`(p,c) \mapsto p * c`, :math:`\;` for :math:`n=1,2,3` - Locate an object :math:`c \in C^n` onto plane :math:`p \in P`, i.e. ``c.moved(p.to_location())`` + Locate an object :math:`c \in C^n` onto plane :math:`p \in P`, i.e. ``c.moved(p.location)`` **Placing objects at locations** diff --git a/docs/assemblies.rst b/docs/assemblies.rst index a11462af..5dcbe9b9 100644 --- a/docs/assemblies.rst +++ b/docs/assemblies.rst @@ -1,3 +1,5 @@ +.. _assembly: + ########## Assemblies ########## @@ -71,6 +73,8 @@ and now the screw is part of the assembly. ├── outer hinge Hinge at 0x7fc9292c3f40, Location(p=(-150, 60, 50), o=(90, 0, 90)) └── M6 screw Compound at 0x7fc8ee235310, Location(p=(-157, -40, 70), o=(-0, -90, -60)) +.. _shallow_copy: + ********************************* Shallow vs. Deep Copies of Shapes ********************************* diff --git a/docs/assets/VSC_debugger.png b/docs/assets/VSC_debugger.png new file mode 100644 index 00000000..fe5b0fb7 Binary files /dev/null and b/docs/assets/VSC_debugger.png differ diff --git a/docs/assets/align.svg b/docs/assets/align.svg new file mode 100644 index 00000000..415adcc3 --- /dev/null +++ b/docs/assets/align.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/arrow.svg b/docs/assets/arrow.svg new file mode 100644 index 00000000..024611ed --- /dev/null +++ b/docs/assets/arrow.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/arrow_head.svg b/docs/assets/arrow_head.svg new file mode 100644 index 00000000..71eecbc5 --- /dev/null +++ b/docs/assets/arrow_head.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/bezier_curve_example.svg b/docs/assets/bezier_curve_example.svg index e6b5181c..1f506f81 100644 --- a/docs/assets/bezier_curve_example.svg +++ b/docs/assets/bezier_curve_example.svg @@ -1,62 +1,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/box_example.svg b/docs/assets/box_example.svg new file mode 100644 index 00000000..eba1ec6d --- /dev/null +++ b/docs/assets/box_example.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/buildline_example_1.svg b/docs/assets/buildline_example_1.svg index 45c25ff3..26ed31f1 100644 --- a/docs/assets/buildline_example_1.svg +++ b/docs/assets/buildline_example_1.svg @@ -1,22 +1,9 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/buildline_example_5.svg b/docs/assets/buildline_example_5.svg index f25e8033..eeb26d88 100644 --- a/docs/assets/buildline_example_5.svg +++ b/docs/assets/buildline_example_5.svg @@ -1,65 +1,17 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/buildline_example_6.svg b/docs/assets/buildline_example_6.svg index ee79a6b2..6c31c81b 100644 --- a/docs/assets/buildline_example_6.svg +++ b/docs/assets/buildline_example_6.svg @@ -1,28 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/docs/assets/buildline_example_7.svg b/docs/assets/buildline_example_7.svg index ea21c672..2f4428a9 100644 --- a/docs/assets/buildline_example_7.svg +++ b/docs/assets/buildline_example_7.svg @@ -1,38 +1,25 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/buildline_example_8.svg b/docs/assets/buildline_example_8.svg index 541ce8f3..46844886 100644 --- a/docs/assets/buildline_example_8.svg +++ b/docs/assets/buildline_example_8.svg @@ -1,65 +1,56 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/card_box.svg b/docs/assets/card_box.svg new file mode 100644 index 00000000..9678525a --- /dev/null +++ b/docs/assets/card_box.svg @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/center.svg b/docs/assets/center.svg new file mode 100644 index 00000000..5d173614 --- /dev/null +++ b/docs/assets/center.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/center_arc_example.svg b/docs/assets/center_arc_example.svg index 67954a8b..fbe13dce 100644 --- a/docs/assets/center_arc_example.svg +++ b/docs/assets/center_arc_example.svg @@ -1,62 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/circle_example.svg b/docs/assets/circle_example.svg new file mode 100644 index 00000000..d5f0a0a8 --- /dev/null +++ b/docs/assets/circle_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/circle_with_hole.svg b/docs/assets/circle_with_hole.svg new file mode 100644 index 00000000..b330ef95 --- /dev/null +++ b/docs/assets/circle_with_hole.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/cone_example.svg b/docs/assets/cone_example.svg new file mode 100644 index 00000000..676a9e13 --- /dev/null +++ b/docs/assets/cone_example.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/controller.svg b/docs/assets/controller.svg new file mode 100644 index 00000000..f1c4c99f --- /dev/null +++ b/docs/assets/controller.svg @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/counter_bore_hole_example.svg b/docs/assets/counter_bore_hole_example.svg new file mode 100644 index 00000000..49b6656d --- /dev/null +++ b/docs/assets/counter_bore_hole_example.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/counter_sink_hole_example.svg b/docs/assets/counter_sink_hole_example.svg new file mode 100644 index 00000000..f34bafb7 --- /dev/null +++ b/docs/assets/counter_sink_hole_example.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/custom_selector.png b/docs/assets/custom_selector.png new file mode 100644 index 00000000..3522b4a7 Binary files /dev/null and b/docs/assets/custom_selector.png differ diff --git a/docs/assets/cylinder_example.svg b/docs/assets/cylinder_example.svg new file mode 100644 index 00000000..7d139b69 --- /dev/null +++ b/docs/assets/cylinder_example.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/d_line.svg b/docs/assets/d_line.svg new file mode 100644 index 00000000..51403c76 --- /dev/null +++ b/docs/assets/d_line.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/e_line.svg b/docs/assets/e_line.svg new file mode 100644 index 00000000..21eea918 --- /dev/null +++ b/docs/assets/e_line.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/ellipse_example.svg b/docs/assets/ellipse_example.svg new file mode 100644 index 00000000..adc2e531 --- /dev/null +++ b/docs/assets/ellipse_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/elliptical_center_arc_example.svg b/docs/assets/elliptical_center_arc_example.svg index bd75beec..ae7426e1 100644 --- a/docs/assets/elliptical_center_arc_example.svg +++ b/docs/assets/elliptical_center_arc_example.svg @@ -1,62 +1,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/filletpolyline_example.svg b/docs/assets/filletpolyline_example.svg new file mode 100644 index 00000000..0b582e12 --- /dev/null +++ b/docs/assets/filletpolyline_example.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex1.svg b/docs/assets/general_ex1.svg index 3651519f..be57a3f3 100644 --- a/docs/assets/general_ex1.svg +++ b/docs/assets/general_ex1.svg @@ -1,32 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex10.svg b/docs/assets/general_ex10.svg index add2e4f7..f9fe1942 100644 --- a/docs/assets/general_ex10.svg +++ b/docs/assets/general_ex10.svg @@ -1,43 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex10_algebra.svg b/docs/assets/general_ex10_algebra.svg new file mode 100644 index 00000000..a52cb0b5 --- /dev/null +++ b/docs/assets/general_ex10_algebra.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex11.svg b/docs/assets/general_ex11.svg index c9be9507..6c2b95b8 100644 --- a/docs/assets/general_ex11.svg +++ b/docs/assets/general_ex11.svg @@ -1,135 +1,124 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex11_algebra.svg b/docs/assets/general_ex11_algebra.svg new file mode 100644 index 00000000..f8a03253 --- /dev/null +++ b/docs/assets/general_ex11_algebra.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex12.svg b/docs/assets/general_ex12.svg index 89fb0545..243efe0f 100644 --- a/docs/assets/general_ex12.svg +++ b/docs/assets/general_ex12.svg @@ -1,39 +1,28 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex12_algebra.svg b/docs/assets/general_ex12_algebra.svg new file mode 100644 index 00000000..9a91fb05 --- /dev/null +++ b/docs/assets/general_ex12_algebra.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex13.svg b/docs/assets/general_ex13.svg index fe926f72..e595c9c1 100644 --- a/docs/assets/general_ex13.svg +++ b/docs/assets/general_ex13.svg @@ -1,168 +1,157 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex13_algebra.svg b/docs/assets/general_ex13_algebra.svg new file mode 100644 index 00000000..ca06ec7a --- /dev/null +++ b/docs/assets/general_ex13_algebra.svg @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex14.svg b/docs/assets/general_ex14.svg index b7a7eda0..e471157c 100644 --- a/docs/assets/general_ex14.svg +++ b/docs/assets/general_ex14.svg @@ -1,57 +1,46 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex14_algebra.svg b/docs/assets/general_ex14_algebra.svg new file mode 100644 index 00000000..7fddd679 --- /dev/null +++ b/docs/assets/general_ex14_algebra.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex15.svg b/docs/assets/general_ex15.svg index 7fc53861..020222f4 100644 --- a/docs/assets/general_ex15.svg +++ b/docs/assets/general_ex15.svg @@ -1,45 +1,34 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex15_algebra.svg b/docs/assets/general_ex15_algebra.svg new file mode 100644 index 00000000..0d9d0ef6 --- /dev/null +++ b/docs/assets/general_ex15_algebra.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex16.svg b/docs/assets/general_ex16.svg index 586e3760..2fd8b7c6 100644 --- a/docs/assets/general_ex16.svg +++ b/docs/assets/general_ex16.svg @@ -1,326 +1,315 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex16_algebra.svg b/docs/assets/general_ex16_algebra.svg new file mode 100644 index 00000000..d9f4e9be --- /dev/null +++ b/docs/assets/general_ex16_algebra.svg @@ -0,0 +1,326 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex17.svg b/docs/assets/general_ex17.svg index 7e688374..095514e2 100644 --- a/docs/assets/general_ex17.svg +++ b/docs/assets/general_ex17.svg @@ -1,44 +1,33 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex17_algebra.svg b/docs/assets/general_ex17_algebra.svg new file mode 100644 index 00000000..16b891f5 --- /dev/null +++ b/docs/assets/general_ex17_algebra.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex18.svg b/docs/assets/general_ex18.svg index 6af6292a..8f0b652f 100644 --- a/docs/assets/general_ex18.svg +++ b/docs/assets/general_ex18.svg @@ -1,69 +1,58 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex18_algebra.svg b/docs/assets/general_ex18_algebra.svg new file mode 100644 index 00000000..e17f67ae --- /dev/null +++ b/docs/assets/general_ex18_algebra.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex19.svg b/docs/assets/general_ex19.svg index 87a849b4..7ec1dbb3 100644 --- a/docs/assets/general_ex19.svg +++ b/docs/assets/general_ex19.svg @@ -1,51 +1,40 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex19_algebra.svg b/docs/assets/general_ex19_algebra.svg new file mode 100644 index 00000000..ac45d45b --- /dev/null +++ b/docs/assets/general_ex19_algebra.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex1_algebra.svg b/docs/assets/general_ex1_algebra.svg new file mode 100644 index 00000000..e4e9f9d3 --- /dev/null +++ b/docs/assets/general_ex1_algebra.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex2.svg b/docs/assets/general_ex2.svg index b2e22e27..3519c347 100644 --- a/docs/assets/general_ex2.svg +++ b/docs/assets/general_ex2.svg @@ -1,42 +1,31 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex20.svg b/docs/assets/general_ex20.svg index 7881cdd7..8f6d33ff 100644 --- a/docs/assets/general_ex20.svg +++ b/docs/assets/general_ex20.svg @@ -1,40 +1,29 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex20_algebra.svg b/docs/assets/general_ex20_algebra.svg new file mode 100644 index 00000000..67848c50 --- /dev/null +++ b/docs/assets/general_ex20_algebra.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex21.svg b/docs/assets/general_ex21.svg index e7812205..18492bf1 100644 --- a/docs/assets/general_ex21.svg +++ b/docs/assets/general_ex21.svg @@ -1,43 +1,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex21_algebra.svg b/docs/assets/general_ex21_algebra.svg new file mode 100644 index 00000000..2ef0f888 --- /dev/null +++ b/docs/assets/general_ex21_algebra.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex22.svg b/docs/assets/general_ex22.svg index b712159e..f9f382c3 100644 --- a/docs/assets/general_ex22.svg +++ b/docs/assets/general_ex22.svg @@ -1,78 +1,67 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex22_algebra.svg b/docs/assets/general_ex22_algebra.svg new file mode 100644 index 00000000..12cac20d --- /dev/null +++ b/docs/assets/general_ex22_algebra.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex23.svg b/docs/assets/general_ex23.svg index d228e7e0..561911bf 100644 --- a/docs/assets/general_ex23.svg +++ b/docs/assets/general_ex23.svg @@ -1,54 +1,43 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex23_algebra.svg b/docs/assets/general_ex23_algebra.svg new file mode 100644 index 00000000..b32a0d03 --- /dev/null +++ b/docs/assets/general_ex23_algebra.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex24.svg b/docs/assets/general_ex24.svg index 00534ae3..74155bfb 100644 --- a/docs/assets/general_ex24.svg +++ b/docs/assets/general_ex24.svg @@ -1,55 +1,44 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex24_algebra.svg b/docs/assets/general_ex24_algebra.svg new file mode 100644 index 00000000..d9e6104d --- /dev/null +++ b/docs/assets/general_ex24_algebra.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex25.svg b/docs/assets/general_ex25.svg index f95659ee..05e08f47 100644 --- a/docs/assets/general_ex25.svg +++ b/docs/assets/general_ex25.svg @@ -1,84 +1,73 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex25_algebra.svg b/docs/assets/general_ex25_algebra.svg new file mode 100644 index 00000000..fcd860c3 --- /dev/null +++ b/docs/assets/general_ex25_algebra.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex26.svg b/docs/assets/general_ex26.svg index 9984c36a..fb6fdca7 100644 --- a/docs/assets/general_ex26.svg +++ b/docs/assets/general_ex26.svg @@ -1,46 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex26_algebra.svg b/docs/assets/general_ex26_algebra.svg new file mode 100644 index 00000000..974f0352 --- /dev/null +++ b/docs/assets/general_ex26_algebra.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex27.svg b/docs/assets/general_ex27.svg index 2ee24bdf..8b8c29e3 100644 --- a/docs/assets/general_ex27.svg +++ b/docs/assets/general_ex27.svg @@ -1,42 +1,31 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex27_algebra.svg b/docs/assets/general_ex27_algebra.svg new file mode 100644 index 00000000..28692381 --- /dev/null +++ b/docs/assets/general_ex27_algebra.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex28.svg b/docs/assets/general_ex28.svg index b467dd07..ce1756e9 100644 --- a/docs/assets/general_ex28.svg +++ b/docs/assets/general_ex28.svg @@ -1,76 +1,65 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex28_algebra.svg b/docs/assets/general_ex28_algebra.svg new file mode 100644 index 00000000..21c6da03 --- /dev/null +++ b/docs/assets/general_ex28_algebra.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex29.svg b/docs/assets/general_ex29.svg index b4a05530..1253ce26 100644 --- a/docs/assets/general_ex29.svg +++ b/docs/assets/general_ex29.svg @@ -1,94 +1,83 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex29_algebra.svg b/docs/assets/general_ex29_algebra.svg new file mode 100644 index 00000000..48f1aa5f --- /dev/null +++ b/docs/assets/general_ex29_algebra.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex2_algebra.svg b/docs/assets/general_ex2_algebra.svg new file mode 100644 index 00000000..ee036033 --- /dev/null +++ b/docs/assets/general_ex2_algebra.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex3.svg b/docs/assets/general_ex3.svg index 539b1a4c..a78b6e0a 100644 --- a/docs/assets/general_ex3.svg +++ b/docs/assets/general_ex3.svg @@ -1,41 +1,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex30.svg b/docs/assets/general_ex30.svg index 0224cb90..2f1596c4 100644 --- a/docs/assets/general_ex30.svg +++ b/docs/assets/general_ex30.svg @@ -1,51 +1,40 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex30_algebra.svg b/docs/assets/general_ex30_algebra.svg new file mode 100644 index 00000000..3a09f98d --- /dev/null +++ b/docs/assets/general_ex30_algebra.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex31.svg b/docs/assets/general_ex31.svg index 3f3fd146..46bf48d8 100644 --- a/docs/assets/general_ex31.svg +++ b/docs/assets/general_ex31.svg @@ -1,356 +1,345 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex31_algebra.svg b/docs/assets/general_ex31_algebra.svg new file mode 100644 index 00000000..f59ce08c --- /dev/null +++ b/docs/assets/general_ex31_algebra.svg @@ -0,0 +1,356 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex32.svg b/docs/assets/general_ex32.svg index 643c4780..9a4fe9ac 100644 --- a/docs/assets/general_ex32.svg +++ b/docs/assets/general_ex32.svg @@ -1,119 +1,108 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex32_algebra.svg b/docs/assets/general_ex32_algebra.svg new file mode 100644 index 00000000..59c79d37 --- /dev/null +++ b/docs/assets/general_ex32_algebra.svg @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex33.svg b/docs/assets/general_ex33.svg index 34951db4..7994a8d3 100644 --- a/docs/assets/general_ex33.svg +++ b/docs/assets/general_ex33.svg @@ -1,92 +1,81 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex33_algebra.svg b/docs/assets/general_ex33_algebra.svg new file mode 100644 index 00000000..e9507ed8 --- /dev/null +++ b/docs/assets/general_ex33_algebra.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex34.svg b/docs/assets/general_ex34.svg index e02aec4c..09fc8470 100644 --- a/docs/assets/general_ex34.svg +++ b/docs/assets/general_ex34.svg @@ -1,497 +1,300 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex34_algebra.svg b/docs/assets/general_ex34_algebra.svg new file mode 100644 index 00000000..404557ba --- /dev/null +++ b/docs/assets/general_ex34_algebra.svg @@ -0,0 +1,449 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex35.svg b/docs/assets/general_ex35.svg index 53c494e7..707c84ec 100644 --- a/docs/assets/general_ex35.svg +++ b/docs/assets/general_ex35.svg @@ -1,98 +1,87 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex35_algebra.svg b/docs/assets/general_ex35_algebra.svg new file mode 100644 index 00000000..4e737226 --- /dev/null +++ b/docs/assets/general_ex35_algebra.svg @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex36.svg b/docs/assets/general_ex36.svg index 3d2ad2d8..f5985d24 100644 --- a/docs/assets/general_ex36.svg +++ b/docs/assets/general_ex36.svg @@ -1,46 +1,35 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex36_algebra.svg b/docs/assets/general_ex36_algebra.svg new file mode 100644 index 00000000..ac4e2058 --- /dev/null +++ b/docs/assets/general_ex36_algebra.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex3_algebra.svg b/docs/assets/general_ex3_algebra.svg new file mode 100644 index 00000000..a4a9f421 --- /dev/null +++ b/docs/assets/general_ex3_algebra.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex4.svg b/docs/assets/general_ex4.svg index f9db4400..2defb09b 100644 --- a/docs/assets/general_ex4.svg +++ b/docs/assets/general_ex4.svg @@ -1,35 +1,24 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex4_algebra.svg b/docs/assets/general_ex4_algebra.svg new file mode 100644 index 00000000..21a11dee --- /dev/null +++ b/docs/assets/general_ex4_algebra.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex5.svg b/docs/assets/general_ex5.svg index 5a48a8c0..177295a5 100644 --- a/docs/assets/general_ex5.svg +++ b/docs/assets/general_ex5.svg @@ -1,49 +1,38 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex5_algebra.svg b/docs/assets/general_ex5_algebra.svg new file mode 100644 index 00000000..88c336a6 --- /dev/null +++ b/docs/assets/general_ex5_algebra.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex6.svg b/docs/assets/general_ex6.svg index e8d4e77d..0b32baeb 100644 --- a/docs/assets/general_ex6.svg +++ b/docs/assets/general_ex6.svg @@ -1,60 +1,49 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex6_algebra.svg b/docs/assets/general_ex6_algebra.svg new file mode 100644 index 00000000..5910ee1d --- /dev/null +++ b/docs/assets/general_ex6_algebra.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex7.svg b/docs/assets/general_ex7.svg index 71df728f..533d6cef 100644 --- a/docs/assets/general_ex7.svg +++ b/docs/assets/general_ex7.svg @@ -1,72 +1,61 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex7_algebra.svg b/docs/assets/general_ex7_algebra.svg new file mode 100644 index 00000000..c1b67afb --- /dev/null +++ b/docs/assets/general_ex7_algebra.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex8.svg b/docs/assets/general_ex8.svg index 8c92b690..05d757e6 100644 --- a/docs/assets/general_ex8.svg +++ b/docs/assets/general_ex8.svg @@ -1,58 +1,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex8_algebra.svg b/docs/assets/general_ex8_algebra.svg new file mode 100644 index 00000000..7fdcaf07 --- /dev/null +++ b/docs/assets/general_ex8_algebra.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex9.svg b/docs/assets/general_ex9.svg index f991bedd..983e7be9 100644 --- a/docs/assets/general_ex9.svg +++ b/docs/assets/general_ex9.svg @@ -1,56 +1,45 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/general_ex9_algebra.svg b/docs/assets/general_ex9_algebra.svg new file mode 100644 index 00000000..8f271fd9 --- /dev/null +++ b/docs/assets/general_ex9_algebra.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/helix_example.svg b/docs/assets/helix_example.svg index 07578f2e..5ab24c71 100644 --- a/docs/assets/helix_example.svg +++ b/docs/assets/helix_example.svg @@ -1,62 +1,49 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/hole_example.svg b/docs/assets/hole_example.svg new file mode 100644 index 00000000..8e7a299c --- /dev/null +++ b/docs/assets/hole_example.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/jern_arc_example.svg b/docs/assets/jern_arc_example.svg index 0a95ee28..13dcbf32 100644 --- a/docs/assets/jern_arc_example.svg +++ b/docs/assets/jern_arc_example.svg @@ -1,62 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/joint-latch-slide.png b/docs/assets/joint-latch-slide.png new file mode 100644 index 00000000..a5f6ff88 Binary files /dev/null and b/docs/assets/joint-latch-slide.png differ diff --git a/docs/assets/joint-latch.png b/docs/assets/joint-latch.png new file mode 100644 index 00000000..8c9534f3 Binary files /dev/null and b/docs/assets/joint-latch.png differ diff --git a/docs/assets/joint-slide.png b/docs/assets/joint-slide.png new file mode 100644 index 00000000..d94dd0c9 Binary files /dev/null and b/docs/assets/joint-slide.png differ diff --git a/docs/assets/lego.svg b/docs/assets/lego.svg new file mode 100644 index 00000000..cb43cde1 --- /dev/null +++ b/docs/assets/lego.svg @@ -0,0 +1,415 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step10.svg b/docs/assets/lego_step10.svg new file mode 100644 index 00000000..6d31bd02 --- /dev/null +++ b/docs/assets/lego_step10.svg @@ -0,0 +1,305 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step4.svg b/docs/assets/lego_step4.svg new file mode 100644 index 00000000..c57d50ef --- /dev/null +++ b/docs/assets/lego_step4.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step5.svg b/docs/assets/lego_step5.svg new file mode 100644 index 00000000..93657bcd --- /dev/null +++ b/docs/assets/lego_step5.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step6.svg b/docs/assets/lego_step6.svg new file mode 100644 index 00000000..3df5e430 --- /dev/null +++ b/docs/assets/lego_step6.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step7.svg b/docs/assets/lego_step7.svg new file mode 100644 index 00000000..9fc66ca7 --- /dev/null +++ b/docs/assets/lego_step7.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step8.svg b/docs/assets/lego_step8.svg new file mode 100644 index 00000000..8e0545fb --- /dev/null +++ b/docs/assets/lego_step8.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/lego_step9.svg b/docs/assets/lego_step9.svg new file mode 100644 index 00000000..8176ee3e --- /dev/null +++ b/docs/assets/lego_step9.svg @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/line_example.svg b/docs/assets/line_example.svg index 73676dff..baab452e 100644 --- a/docs/assets/line_example.svg +++ b/docs/assets/line_example.svg @@ -1,62 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/line_types.svg b/docs/assets/line_types.svg new file mode 100644 index 00000000..b09f0be4 --- /dev/null +++ b/docs/assets/line_types.svg @@ -0,0 +1,959 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/one_d_center.svg b/docs/assets/one_d_center.svg new file mode 100644 index 00000000..80162c8d --- /dev/null +++ b/docs/assets/one_d_center.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/plate.svg b/docs/assets/plate.svg new file mode 100644 index 00000000..9f7d63cb --- /dev/null +++ b/docs/assets/plate.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/polar_line_example.svg b/docs/assets/polar_line_example.svg index d5801d33..bee604be 100644 --- a/docs/assets/polar_line_example.svg +++ b/docs/assets/polar_line_example.svg @@ -1,62 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/polygon_example.svg b/docs/assets/polygon_example.svg new file mode 100644 index 00000000..97bb31c7 --- /dev/null +++ b/docs/assets/polygon_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/polyline_example.svg b/docs/assets/polyline_example.svg index eb941acf..7ce72158 100644 --- a/docs/assets/polyline_example.svg +++ b/docs/assets/polyline_example.svg @@ -1,63 +1,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/radius_arc_example.svg b/docs/assets/radius_arc_example.svg index a55838a8..862220a0 100644 --- a/docs/assets/radius_arc_example.svg +++ b/docs/assets/radius_arc_example.svg @@ -1,62 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/rectangle_example.svg b/docs/assets/rectangle_example.svg new file mode 100644 index 00000000..50b0c940 --- /dev/null +++ b/docs/assets/rectangle_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/rectangle_rounded_example.svg b/docs/assets/rectangle_rounded_example.svg new file mode 100644 index 00000000..78cd6b5f --- /dev/null +++ b/docs/assets/rectangle_rounded_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/regular_polygon_example.svg b/docs/assets/regular_polygon_example.svg new file mode 100644 index 00000000..ba584e8f --- /dev/null +++ b/docs/assets/regular_polygon_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/rigid_joints_pipe.png b/docs/assets/rigid_joints_pipe.png new file mode 100644 index 00000000..23c2cb2f Binary files /dev/null and b/docs/assets/rigid_joints_pipe.png differ diff --git a/docs/assets/rod_end.png b/docs/assets/rod_end.png new file mode 100644 index 00000000..915144bc Binary files /dev/null and b/docs/assets/rod_end.png differ diff --git a/docs/assets/sagitta_arc_example.svg b/docs/assets/sagitta_arc_example.svg index cf112391..1af4ab2d 100644 --- a/docs/assets/sagitta_arc_example.svg +++ b/docs/assets/sagitta_arc_example.svg @@ -1,62 +1,10 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/selector_after.svg b/docs/assets/selector_after.svg new file mode 100644 index 00000000..cc162e88 --- /dev/null +++ b/docs/assets/selector_after.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/selector_before.svg b/docs/assets/selector_before.svg new file mode 100644 index 00000000..7729c0f1 --- /dev/null +++ b/docs/assets/selector_before.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/slot_arc_example.svg b/docs/assets/slot_arc_example.svg new file mode 100644 index 00000000..dcd98f3c --- /dev/null +++ b/docs/assets/slot_arc_example.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/slot_center_point_example.svg b/docs/assets/slot_center_point_example.svg new file mode 100644 index 00000000..f32b3aac --- /dev/null +++ b/docs/assets/slot_center_point_example.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/slot_center_to_center_example.svg b/docs/assets/slot_center_to_center_example.svg new file mode 100644 index 00000000..81a441cf --- /dev/null +++ b/docs/assets/slot_center_to_center_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/slot_overall_example.svg b/docs/assets/slot_overall_example.svg new file mode 100644 index 00000000..069abec5 --- /dev/null +++ b/docs/assets/slot_overall_example.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/docs/assets/sphere_example.svg b/docs/assets/sphere_example.svg new file mode 100644 index 00000000..7c00d91f --- /dev/null +++ b/docs/assets/sphere_example.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/spline_example.svg b/docs/assets/spline_example.svg index 89e28b6f..2d6885e5 100644 --- a/docs/assets/spline_example.svg +++ b/docs/assets/spline_example.svg @@ -1,62 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tangent_arc_example.svg b/docs/assets/tangent_arc_example.svg index c3197e6c..53903ed7 100644 --- a/docs/assets/tangent_arc_example.svg +++ b/docs/assets/tangent_arc_example.svg @@ -1,62 +1,13 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tech_drawing.svg b/docs/assets/tech_drawing.svg new file mode 100644 index 00000000..7279403d --- /dev/null +++ b/docs/assets/tech_drawing.svg @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/text_example.svg b/docs/assets/text_example.svg new file mode 100644 index 00000000..5c61dfb1 --- /dev/null +++ b/docs/assets/text_example.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/three_point_arc_example.svg b/docs/assets/three_point_arc_example.svg index 4ec28e6f..6e889961 100644 --- a/docs/assets/three_point_arc_example.svg +++ b/docs/assets/three_point_arc_example.svg @@ -1,62 +1,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/torus_example.svg b/docs/assets/torus_example.svg new file mode 100644 index 00000000..88d2c2e7 --- /dev/null +++ b/docs/assets/torus_example.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/trapezoid_example.svg b/docs/assets/trapezoid_example.svg new file mode 100644 index 00000000..c1c40bee --- /dev/null +++ b/docs/assets/trapezoid_example.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint.svg b/docs/assets/tutorial_joint.svg index cd953e77..07b8bb98 100644 --- a/docs/assets/tutorial_joint.svg +++ b/docs/assets/tutorial_joint.svg @@ -1,631 +1,631 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_box.svg b/docs/assets/tutorial_joint_box.svg index 57d4a9a8..7eef59d1 100644 --- a/docs/assets/tutorial_joint_box.svg +++ b/docs/assets/tutorial_joint_box.svg @@ -1,125 +1,114 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_box_outer.svg b/docs/assets/tutorial_joint_box_outer.svg index 451dd309..6fe6725f 100644 --- a/docs/assets/tutorial_joint_box_outer.svg +++ b/docs/assets/tutorial_joint_box_outer.svg @@ -1,246 +1,243 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_box_outer_inner.svg b/docs/assets/tutorial_joint_box_outer_inner.svg index 0be7ea95..4dcd9e15 100644 --- a/docs/assets/tutorial_joint_box_outer_inner.svg +++ b/docs/assets/tutorial_joint_box_outer_inner.svg @@ -1,330 +1,329 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_box_outer_inner_lid.svg b/docs/assets/tutorial_joint_box_outer_inner_lid.svg index 1d81beed..b97328a2 100644 --- a/docs/assets/tutorial_joint_box_outer_inner_lid.svg +++ b/docs/assets/tutorial_joint_box_outer_inner_lid.svg @@ -1,341 +1,338 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_inner_leaf.svg b/docs/assets/tutorial_joint_inner_leaf.svg index 6007adb7..04b0169b 100644 --- a/docs/assets/tutorial_joint_inner_leaf.svg +++ b/docs/assets/tutorial_joint_inner_leaf.svg @@ -1,212 +1,205 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_lid.svg b/docs/assets/tutorial_joint_lid.svg index 2f61bd68..1c23244a 100644 --- a/docs/assets/tutorial_joint_lid.svg +++ b/docs/assets/tutorial_joint_lid.svg @@ -1,99 +1,88 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_m6_screw.svg b/docs/assets/tutorial_joint_m6_screw.svg index 569d1dd4..51065999 100644 --- a/docs/assets/tutorial_joint_m6_screw.svg +++ b/docs/assets/tutorial_joint_m6_screw.svg @@ -1,187 +1,368 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/tutorial_joint_outer_leaf.svg b/docs/assets/tutorial_joint_outer_leaf.svg index f59043e9..a6102459 100644 --- a/docs/assets/tutorial_joint_outer_leaf.svg +++ b/docs/assets/tutorial_joint_outer_leaf.svg @@ -1,223 +1,221 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/assets/wedge_example.svg b/docs/assets/wedge_example.svg new file mode 100644 index 00000000..2cb7060f --- /dev/null +++ b/docs/assets/wedge_example.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/build_line.rst b/docs/build_line.rst index 65b132d1..70c7f2a3 100644 --- a/docs/build_line.rst +++ b/docs/build_line.rst @@ -6,8 +6,7 @@ BuildLine is a python context manager that is used to create one dimensional objects - objects with the property of length but not area - that are typically used as part of a BuildSketch sketch or a BuildPart path. -The complete API for BuildLine is located here in the -:ref:`Builder API Reference `. +The complete API for BuildLine is located at the end of this section. ******************* Basic Functionality @@ -15,14 +14,14 @@ Basic Functionality The following is a simple BuildLine example: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 1] :end-before: [Ex. 1] The ``with`` statement creates the ``BuildLine`` context manager with the identifier ``example_1``. The objects and operations that are within the scope (i.e. indented) of this context will contribute towards the object -being created by the context manager. For BuildLine, this object is +being created by the context manager. For ``BuildLine``, this object is ``line`` and it's referenced as ``example_1.line``. The first object in this example is a ``Line`` object which is used to create @@ -50,7 +49,7 @@ two ends of the ``Line`` but this was done by referring to the same point ``(0,0)`` and ``(2,0)``. This can be improved upon by specifying constraints that lock the arc to those two end points, as follows: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 2] :end-before: [Ex. 2] @@ -63,7 +62,7 @@ at this fractional position along the line's length. This example can be improved on further by calculating the mid-point of the arc as follows: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 3] :end-before: [Ex. 3] @@ -73,7 +72,7 @@ a vector addition to generate the point ``(1,1)``. To make the design even more parametric, the height of the arc can be calculated from ``l1`` as follows: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 4] :end-before: [Ex. 4] @@ -87,11 +86,11 @@ fully parametric and able to generate the same shape for any horizontal line. The other operator that is commonly used within BuildLine is ``%`` the tangent at operator. Here is another example: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 5] :end-before: [Ex. 5] -which generates: +which generates (note that the circles show line junctions): .. image:: assets/buildline_example_5.svg :align: center @@ -100,7 +99,7 @@ The ``JernArc`` has the following parameters: * ``start=l2 @ 1`` - start the arc at the end of line ``l2``, * ``tangent=l2 % 1`` - the tangent of the arc at the start point is equal to the ``l2``\'s, - tangent at its end + tangent at its end (shown as a dashed line) * ``radius=0.5`` - the radius of the arc, and * ``arc_size=90`` the angular size of the arc. @@ -124,7 +123,7 @@ objects. Here is an example of using BuildLine to create an object that otherwise might be difficult to create: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 6] :end-before: [Ex. 6] @@ -152,10 +151,10 @@ BuildLine to BuildPart ********************** The other primary reasons to use BuildLine is to create paths for BuildPart -:meth:`~operations_part.sweep` operations. Here some curved and straight segments +:meth:`~operations_generic.sweep` operations. Here some curved and straight segments define a path: -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 7] :end-before: [Ex. 7] @@ -176,106 +175,6 @@ There are few things to note from this example: ``Sweep`` operation "consumes" these pending objects as to not interfere with subsequence operations. -***************** -BuildLine Objects -***************** - -The following objects all can be used in BuildLine contexts. - -.. grid:: 3 - - .. grid-item-card:: :class:`~objects_curve.Bezier` - - .. image:: assets/bezier_curve_example.svg - - +++ - Curve defined by control points and weights - - .. grid-item-card:: :class:`~objects_curve.CenterArc` - - .. image:: assets/center_arc_example.svg - - +++ - Arc defined by center, radius, & angles - - .. grid-item-card:: :class:`~objects_curve.EllipticalCenterArc` - - .. image:: assets/elliptical_center_arc_example.svg - - +++ - Elliptical arc defined by center, radii & angles - - .. grid-item-card:: :class:`~objects_curve.Helix` - - .. image:: assets/helix_example.svg - - +++ - Helix defined pitch, radius and height - - .. grid-item-card:: :class:`~objects_curve.JernArc` - - .. image:: assets/jern_arc_example.svg - - +++ - Arc define by start point, tangent, radius and angle - - .. grid-item-card:: :class:`~objects_curve.Line` - - .. image:: assets/line_example.svg - - +++ - Line defined by end points - - .. grid-item-card:: :class:`~objects_curve.PolarLine` - - .. image:: assets/polar_line_example.svg - - +++ - Line defined by start, angle and length - - .. grid-item-card:: :class:`~objects_curve.Polyline` - - .. image:: assets/polyline_example.svg - - +++ - Multiple line segments defined by points - - .. grid-item-card:: :class:`~objects_curve.RadiusArc` - - .. image:: assets/radius_arc_example.svg - - +++ - Arc define by two points and a radius - - .. grid-item-card:: :class:`~objects_curve.SagittaArc` - - .. image:: assets/sagitta_arc_example.svg - - +++ - Arc define by two points and a sagitta - - .. grid-item-card:: :class:`~objects_curve.Spline` - - .. image:: assets/spline_example.svg - - +++ - Curve define by points - - .. grid-item-card:: :class:`~objects_curve.TangentArc` - - .. image:: assets/tangent_arc_example.svg - - +++ - Curve define by two points and a tangent - - .. grid-item-card:: :class:`~objects_curve.ThreePointArc` - - .. image:: assets/three_point_arc_example.svg - - +++ - Curve define by three points - - *********************** Working on other Planes *********************** @@ -284,7 +183,7 @@ So far all of the examples were created on ``Plane.XY`` - the default plane - wh to global coordinates. Sometimes it's convenient to work on another plane, especially when creating paths for BuildPart ``Sweep`` operations. -.. literalinclude:: buildline_examples.py +.. literalinclude:: objects_1d.py :start-after: [Ex. 8] :end-before: [Ex. 8] @@ -316,4 +215,12 @@ There are three rules to keep in mind when working with alternate planes in Buil Finally, BuildLine's workplane need not be one of the predefined ordinal planes, it could be one created from a surface of a BuildPart part that is currently under -construction. \ No newline at end of file +construction. + +********* +Reference +********* +.. py:module:: build_line + +.. autoclass:: BuildLine + :members: diff --git a/docs/build_part.rst b/docs/build_part.rst index 99c7e895..d520df8f 100644 --- a/docs/build_part.rst +++ b/docs/build_part.rst @@ -1,3 +1,94 @@ ######### BuildPart ######### + +BuildPart is a python context manager that is used to create three dimensional +objects - objects with the property of volume - that are typically +finished parts. + +The complete API for BuildPart is located at the end of this section. + +******************* +Basic Functionality +******************* + +The following is a simple BuildPart example: + +.. literalinclude:: general_examples.py + :start-after: [Ex. 2] + :end-before: [Ex. 2] + +The ``with`` statement creates the ``BuildPart`` context manager with the +identifier ``ex2`` (this code is the second of the introductory examples). +The objects and operations that are within the +scope (i.e. indented) of this context will contribute towards the object +being created by the context manager. For ``BuildPart``, this object is +``part`` and it's referenced as ``ex2.part``. + +The first object in this example is a ``Box`` object which is used to create +a polyhedron with rectangular faces centered on the default ``Plane.XY``. +The second object is a ``Cylinder`` that is subtracted from the box as directed +by the ``mode=Mode.SUBTRACT`` parameter thus creating a hole. + +.. image:: assets/general_ex2.svg + :align: center + +******************* +Implicit Parameters +******************* + +The BuildPart context keeps track of pending objects such that they can be used +implicitly - there are a couple things to consider when deciding how to proceed: + +* For sketches, the planes that they were constructed on is maintained in internal + data structures such that operations like :func:`~operations_part.extrude` will + have a good reference for the extrude direction. One can pass a Face to extrude + but it will then be forced to use the normal direction at the center of the Face + as the extrude direction which unfortunately can be reversed in some circumstances. +* Implicit parameters save some typing but hide some functionality - users have + to decide what works best for them. + +This tea cup example uses implicit parameters - note the :func:`~operations_generic.sweep` +operation on the last line: + +.. literalinclude:: ../examples/tea_cup.py + :lines: 25-74 + :emphasize-lines: 50 + +:func:`~operations_generic.sweep` requires a 2D cross section - ``handle_cross_section`` - +and a path - ``handle_path`` - which are both passed implicitly. + +.. image:: tea_cup.png + :align: center + +***** +Units +***** + +Parts created with build123d have no inherent units associated with them. However, when +exporting parts to external formats like ``STL`` or ``STEP`` the units are assumed to +be millimeters (mm). To be more explicit with units one can use the technique shown +in the above tea cup example where linear dimensions are followed by ``* MM`` which +multiplies the dimension by the ``MM`` scaling factor - in this case ``1``. + +The following dimensional constants are pre-defined: + +.. code:: python + + MM = 1 + CM = 10 * MM + M = 1000 * MM + IN = 25.4 * MM + FT = 12 * IN + THOU = IN / 1000 + +Some export formats like DXF have the ability to explicitly set the units used. + +********* +Reference +********* + +.. py:module:: build_part + +.. autoclass:: BuildPart + :members: diff --git a/docs/build_sketch.rst b/docs/build_sketch.rst index c8ac718f..00c03248 100644 --- a/docs/build_sketch.rst +++ b/docs/build_sketch.rst @@ -1,3 +1,137 @@ ########### BuildSketch ########### + +BuildSketch is a python context manager that is used to create planar two dimensional +objects - objects with the property of area but not volume - that are typically +used as profiles for BuildPart operations like :func:`~operations_part.extrude` or +:func:`~operations_part.revolve`. + +The complete API for BuildSketch is located at the end of this section. + +******************* +Basic Functionality +******************* + +The following is a simple BuildSketch example: + +.. literalinclude:: objects_2d.py + :start-after: [Ex. 13] + :end-before: [Ex. 13] + +The ``with`` statement creates the ``BuildSketch`` context manager with the +identifier ``circle_with_hole``. The objects and operations that are within the +scope (i.e. indented) of this context will contribute towards the object +being created by the context manager. For ``BuildSketch``, this object is +``sketch`` and it's referenced as ``circle_with_hole.sketch``. + +The first object in this example is a ``Circle`` object which is used to create +a filled circular shape on the default XY plane. The second object is a ``Rectangle`` +that is subtracted from the circle as directed by the ``mode=Mode.SUBTRACT`` parameter. +A key aspect of sketch objects is that they are all filled shapes and not just +a shape perimeter which enables combining subsequent shapes with different modes +(the valid values of Mode are ``ADD``, ``SUBTRACT``, ``INTERSECT``, ``REPLACE``, +and ``PRIVATE``). + +.. image:: assets/circle_with_hole.svg + :align: center + +************************* +Sketching on other Planes +************************* + +Often when designing parts one needs to build on top of other features. To facilitate +doing this ``BuildSketch`` allows one to create sketches on any Plane while allowing +the designer to work in a local X, Y coordinate system. It might be helpful to think +of what is happening with this metaphor: + +#. When instantiating ``BuildSketch`` one or more workplanes can be passed as parameters. + These are the placement targets for the completed sketch. +#. The designer draws on a flat "drafting table" which is ``Plane.XY``. +#. Once the sketch is complete, it's applied like a sticker to all of the workplanes + passed in step 1. + +As an example, let's build the following simple control box with a display on an angled plane: + +.. image:: assets/controller.svg + :align: center + +Here is the code: + +.. literalinclude:: objects_2d.py + :start-after: [Ex. 14] + :end-before: [Ex. 14] + :emphasize-lines: 14-25 + +The highlighted part of the code shows how a face is extracted from the design, +a workplane is constructed from this face and finally this workplane is passed +to ``BuildSketch`` as the target for the complete sketch. Notice how the +``display`` sketch uses local coordinates for its features thus avoiding having +the user to determine how to move and rotate the sketch to get it where it +should go. + +Note that ``BuildSketch`` accepts a sequence planes, faces and locations for +workplanes so creation of an explicit workplane is often not required. Being +able to work on multiple workplanes at once allows for features to be created +on multiple side of an object - say both the top and bottom - which is convenient +for symmetric parts. + +************************* +Local vs. Global Sketches +************************* + +In the above example the target for the sketch was not ``Plane.XY`` but a workplane +passed by the user. Internally ``BuildSketch`` is always creating the sketch +on ``Plane.XY`` which one can see by looking at the ``sketch_local`` property of your +sketch. For example, to display the local version of the ``display`` sketch from +above, one would use: + +.. code-block:: python + + show_object(display.sketch_local, name="sketch on Plane.XY") + +while the sketches as applied to their target workplanes is accessible through +the ``sketch`` property, as follows: + +.. code-block:: python + + show_object(display.sketch, name="sketch on target workplane(s)") + +When using the :func:`~operations_generic.add` operation to add an external Face +to a sketch the face will automatically be reoriented to ``Plane.XY`` before being +combined with the sketch. As Faces don't provide an x-direction it's possible +that the new Face may not be oriented as expected. To reorient the Face manually +to ``Plane.XY`` one can use the :meth:`~geometry.to_local_coords` method as +follows: + +.. code-block:: python + + reoriented_face = plane.to_local_coords(face) + +where ``plane`` is the plane that ``face`` was constructed on. + +***************** +Locating Features +***************** + +Within a sketch features are positioned with ``Locations`` contexts +(see :ref:`Location Context `) on the current workplane(s). The following +location contexts are available within a sketch: + +* :class:`~build_common.GridLocations` : a X/Y grid of locations +* :class:`~build_common.HexLocations` : a hex grid of locations ideal for nesting circles +* :class:`~build_common.Locations` : a sequence of arbitrary locations +* :class:`~build_common.PolarLocations` : locations defined by radius and angle + +Generally one would specify tuples of (X, Y) values when defining locations but +there are many options available to the user. + + +********* +Reference +********* + +.. py:module:: build_sketch + +.. autoclass:: BuildSketch + :members: diff --git a/docs/builder_api_reference.rst b/docs/builder_api_reference.rst index 97a434ef..1f5a8af3 100644 --- a/docs/builder_api_reference.rst +++ b/docs/builder_api_reference.rst @@ -1,8 +1,10 @@ .. _builder_api_reference: -##################### -Builder API Reference -##################### +############################ +Builder Common API Reference +############################ + +The following are common to all the builders. **************** Selector Methods @@ -32,14 +34,6 @@ Enums .. autoclass:: Transition .. autoclass:: Until -********** -Workplanes -********** - -.. py:module:: build_common - -.. autoclass:: Workplanes - ********* Locations ********* @@ -48,126 +42,3 @@ Locations .. autoclass:: build_common::GridLocations .. autoclass:: build_common::HexLocations .. autoclass:: build_common::PolarLocations - -****************************** -Generic Objects and Operations -****************************** - -There are several objects and operations that apply to more than one type -of builder which are listed here. The builder that these operations apply -to is determined by context. - -.. py:module:: operations_generic - -======= -Objects -======= -.. autoclass:: add - -========== -Operations -========== -.. autoclass:: bounding_box -.. autoclass:: chamfer -.. autoclass:: fillet -.. autoclass:: mirror -.. autoclass:: offset -.. autoclass:: scale -.. autoclass:: split - -********* -BuildLine -********* -.. py:module:: build_line - -.. autoclass:: BuildLine - :members: - -.. py:module:: objects_curve - -======= -Objects -======= -.. autoclass:: Bezier -.. autoclass:: CenterArc -.. autoclass:: EllipticalCenterArc -.. autoclass:: Helix -.. autoclass:: JernArc -.. autoclass:: Line -.. autoclass:: PolarLine -.. autoclass:: Polyline -.. autoclass:: RadiusArc -.. autoclass:: SagittaArc -.. autoclass:: Spline -.. autoclass:: TangentArc -.. autoclass:: ThreePointArc - -*********** -BuildSketch -*********** - -.. py:module:: build_sketch - -.. autoclass:: BuildSketch - :members: - -.. py:module:: objects_sketch - -======= -Objects -======= -.. autoclass:: Circle -.. autoclass:: Ellipse -.. autoclass:: Polygon -.. autoclass:: Rectangle -.. autoclass:: RectangleRounded -.. autoclass:: RegularPolygon -.. autoclass:: SlotArc -.. autoclass:: SlotCenterPoint -.. autoclass:: SlotCenterToCenter -.. autoclass:: SlotOverall -.. autoclass:: Text -.. autoclass:: Trapezoid - -.. py:module:: operations_sketch - -========== -Operations -========== -.. autoclass:: make_face -.. autoclass:: make_hull - -********* -BuildPart -********* - -.. py:module:: build_part - -.. autoclass:: BuildPart - :members: - -.. py:module:: objects_part - -======= -Objects -======= -.. autoclass:: Box -.. autoclass:: Cone -.. autoclass:: Cylinder -.. autoclass:: Sphere -.. autoclass:: Torus -.. autoclass:: Wedge -.. autoclass:: CounterBoreHole -.. autoclass:: CounterSinkHole -.. autoclass:: Hole - -.. py:module:: operations_part - -========== -Operations -========== -.. autoclass:: extrude -.. autoclass:: loft -.. autoclass:: revolve -.. autoclass:: section -.. autoclass:: sweep diff --git a/docs/buildline_examples.py b/docs/buildline_examples.py deleted file mode 100644 index 2f151de1..00000000 --- a/docs/buildline_examples.py +++ /dev/null @@ -1,175 +0,0 @@ -# [Setup] -from build123d import * - -# [Setup] -svg_opts1 = {"pixel_scale": 100, "show_axes": False, "show_hidden": False} -svg_opts2 = {"pixel_scale": 50, "show_axes": True, "show_hidden": False} - -# [Ex. 1] -with BuildLine() as example_1: - Line((0, 0), (2, 0)) - ThreePointArc((0, 0), (1, 1), (2, 0)) -# [Ex. 1] -example_1.line.export_svg( - "assets/buildline_example_1.svg", (0, 0, 100), (0, 1, 0), svg_opts=svg_opts1 -) -# [Ex. 2] -with BuildLine() as example_2: - l1 = Line((0, 0), (2, 0)) - l2 = ThreePointArc(l1 @ 0, (1, 1), l1 @ 1) -# [Ex. 2] - -# [Ex. 3] -with BuildLine() as example_3: - l1 = Line((0, 0), (2, 0)) - l2 = ThreePointArc(l1 @ 0, l1 @ 0.5 + (0, 1), l1 @ 1) -# [Ex. 3] - -# [Ex. 4] -with BuildLine() as example_4: - l1 = Line((0, 0), (2, 0)) - l2 = ThreePointArc(l1 @ 0, l1 @ 0.5 + (0, l1.length / 2), l1 @ 1) -# [Ex. 4] - -# [Ex. 5] -with BuildLine() as example_5: - l1 = Line((0, 0), (5, 0)) - l2 = Line(l1 @ 1, l1 @ 1 + (0, l1.length - 1)) - l3 = JernArc(start=l2 @ 1, tangent=l2 % 1, radius=0.5, arc_size=90) - l4 = Line(l3 @ 1, (0, l2.length + l3.radius)) -# [Ex. 5] -example_5.line.export_svg( - "assets/buildline_example_5.svg", (0, 0, 100), (0, 1, 0), svg_opts=svg_opts2 -) - - -# [Ex. 6] -with BuildSketch() as example_6: - with BuildLine() as club_outline: - l0 = Line((0, -188), (76, -188)) - b0 = Bezier(l0 @ 1, (61, -185), (33, -173), (17, -81)) - b1 = Bezier(b0 @ 1, (49, -128), (146, -145), (167, -67)) - b2 = Bezier(b1 @ 1, (187, 9), (94, 52), (32, 18)) - b3 = Bezier(b2 @ 1, (92, 57), (113, 188), (0, 188)) - mirror(about=Plane.YZ) - make_face() - # [Ex. 6] - scale(by=2 / example_6.sketch.bounding_box().size.Y) -example_6.sketch.export_svg( - "assets/buildline_example_6.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts1 -) - -# [Ex. 7] -with BuildPart() as example_7: - with BuildLine() as example_7_path: - l1 = RadiusArc((0, 0), (1, 1), 2) - l2 = Spline(l1 @ 1, (2, 3), (3, 3), tangents=(l1 % 1, (0, -1))) - l3 = Line(l2 @ 1, (3, 0)) - with BuildSketch(Plane(origin=l1 @ 0, z_dir=l1 % 0)) as example_7_section: - Circle(0.1) - sweep() -# [Ex. 7] -example_7.part.export_svg( - "assets/buildline_example_7.svg", (100, -50, 100), (0, 0, 1), svg_opts=svg_opts1 -) - -# [Ex. 8] -with BuildLine(Plane.YZ) as example_8: - l1 = Line((0, 0), (5, 0)) - l2 = Line(l1 @ 1, l1 @ 1 + (0, l1.length - 1)) - l3 = JernArc(start=l2 @ 1, tangent=l2 % 1, radius=0.5, arc_size=90) - l4 = Line(l3 @ 1, (0, l2.length + l3.radius)) -# [Ex. 8] -example_8.line.export_svg( - "assets/buildline_example_8.svg", (100, -50, 100), (0, 0, 1), svg_opts=svg_opts2 -) - -pts = [(0, 0), (2 / 3, 2 / 3), (0, 4 / 3), (-4 / 3, 0), (0, -2), (4, 0), (0, 3)] -wts = [1.0, 1.0, 2.0, 3.0, 4.0, 2.0, 1.0] -with BuildLine() as bezier_curve: - Bezier(*pts, weights=wts) -bezier_curve.line.export_svg( - "assets/bezier_curve_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as center_arc: - CenterArc((0, 0), 3, 0, 90) -center_arc.line.export_svg( - "assets/center_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as elliptical_center_arc: - EllipticalCenterArc((0, 0), 2, 3, 0, 90) -elliptical_center_arc.line.export_svg( - "assets/elliptical_center_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as helix: - Helix(1, 3, 1) -helix.line.export_svg( - "assets/helix_example.svg", (1, -1, 1), (0, 0, 1), svg_opts=svg_opts2 -) - -with BuildLine() as jern_arc: - JernArc((1, 1), (1, 0.5), 2, 100) -jern_arc.line.export_svg( - "assets/jern_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as line: - Line((1, 1), (3, 3)) -line.line.export_svg( - "assets/line_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as polar_line: - PolarLine((1, 1), 2.5, 60) -polar_line.line.export_svg( - "assets/polar_line_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as polyline: - Polyline((1, 1), (1.5, 2.5), (3, 3)) -polyline.line.export_svg( - "assets/polyline_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as radius_arc: - RadiusArc((1, 1), (3, 3), 2) -radius_arc.line.export_svg( - "assets/radius_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as sagitta_arc: - SagittaArc((1, 1), (3, 1), 1) -sagitta_arc.line.export_svg( - "assets/sagitta_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as spline: - Spline((1, 1), (2, 1.5), (1, 2), (2, 2.5), (1, 3)) -spline.line.export_svg( - "assets/spline_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as tangent_arc: - TangentArc((1, 1), (3, 3), tangent=(1, 0)) -tangent_arc.line.export_svg( - "assets/tangent_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -with BuildLine() as three_point_arc: - ThreePointArc((1, 1), (1.5, 2), (3, 3)) -three_point_arc.line.export_svg( - "assets/three_point_arc_example.svg", (0, 0, 1), (0, 1, 0), svg_opts=svg_opts2 -) - -if "show_object" in locals(): - # show_object(example_1.line, name="Ex. 1") - # show_object(example_2.line, name="Ex. 2") - # show_object(example_3.line, name="Ex. 3") - # show_object(example_4.line, name="Ex. 4") - # show_object(example_5.line, name="Ex. 5") - # show_object(example_6.line, name="Ex. 6") - # show_object(example_7_path.line, name="Ex. 7 path") - show_object(example_8.line, name="Ex. 8") diff --git a/docs/center.py b/docs/center.py new file mode 100644 index 00000000..798e112c --- /dev/null +++ b/docs/center.py @@ -0,0 +1,35 @@ +from build123d import * +from ocp_vscode import * + +size = 50 +# +# Symbols +# +bbox_symbol = Rectangle(4, 4) +geom_symbol = RegularPolygon(2, 3) +mass_symbol = Circle(2) + +# +# 2D Center Options +# +triangle = RegularPolygon(size / 1.866, 3, rotation=90) +svg = ExportSVG(margin=5) +svg.add_layer("bbox", line_type=LineType.DASHED) +svg.add_shape(bounding_box(triangle), "bbox") +svg.add_shape(triangle) +svg.add_shape(bbox_symbol.located(Location(triangle.center(CenterOf.BOUNDING_BOX)))) +svg.add_shape(mass_symbol.located(Location(triangle.center(CenterOf.MASS)))) +svg.write("assets/center.svg") + +# +# 1D Center Options +# +line = TangentArc((0, 0), (size, size), tangent=(1, 0)) +svg = ExportSVG(margin=5) +svg.add_layer("bbox", line_type=LineType.DASHED) +svg.add_shape(line) +svg.add_shape(Polyline((0, 0), (size, 0), (size, size), (0, size), (0, 0)), "bbox") +svg.add_shape(bbox_symbol.located(Location(line.center(CenterOf.BOUNDING_BOX)))) +svg.add_shape(mass_symbol.located(Location(line.center(CenterOf.MASS)))) +svg.add_shape(geom_symbol.located(Location(line.center(CenterOf.GEOMETRY)))) +svg.write("assets/one_d_center.svg") diff --git a/docs/center.rst b/docs/center.rst index de150ccd..962594e8 100644 --- a/docs/center.rst +++ b/docs/center.rst @@ -1,3 +1,26 @@ ################## CAD Object Centers ################## + +Finding the center of a CAD object is a surprisingly complex operation. To illustrate +let's consider two examples: a simple isosceles triangle and a curved line (their bounding +boxes are shown with dashed lines): + +.. image:: assets/center.svg + :width: 49% + +.. image:: assets/one_d_center.svg + :width: 49% + + +One can see that there is are significant differences between the different types of +centers. To allow the designer to choose the center that makes the most sense for the given +shape there are three possible values for the :class:`~build_enums.CenterOf` Enum: + +============================== ====== == == == ======== +:class:`~build_enums.CenterOf` Symbol 1D 2D 3D Compound +============================== ====== == == == ======== +CenterOf.BOUNDING_BOX □ ✓ ✓ ✓ ✓ +CenterOf.GEOMETRY △ ✓ ✓ +CenterOf.MASS ○ ✓ ✓ ✓ ✓ +============================== ====== == == == ======== diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index f00727a2..b6c6688e 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -8,7 +8,6 @@ Cheat Sheet | :class:`~build_line.BuildLine` :class:`~build_part.BuildPart` :class:`~build_sketch.BuildSketch` | :class:`~build_common.GridLocations` :class:`~build_common.HexLocations` :class:`~build_common.Locations` :class:`~build_common.PolarLocations` - | :class:`~build_common.Workplanes` .. card:: Objects @@ -81,6 +80,8 @@ Cheat Sheet | :func:`~operations_generic.offset` | :func:`~operations_generic.scale` | :func:`~operations_generic.split` + | :func:`~operations_generic.sweep` + | :func:`~operations_sketch.trace` .. grid-item-card:: 3D - BuildPart @@ -95,7 +96,7 @@ Cheat Sheet | :func:`~operations_generic.scale` | :func:`~operations_part.section` | :func:`~operations_generic.split` - | :func:`~operations_part.sweep` + | :func:`~operations_generic.sweep` .. card:: Selectors @@ -201,30 +202,48 @@ Cheat Sheet .. card:: Enums - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Align` | MIN, CENTER, MAX | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.ApproxOption` | ARC, NONE, SPLINE | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.CenterOf` | GEOMETRY, MASS, BOUNDING_BOX | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.FontStyle` | REGULAR, BOLD, ITALIC | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.GeomType` | BEZIER, BSPLINE, CIRCLE, CONE, CYLINDER, ELLIPSE, EXTRUSION, HYPERBOLA, LINE, OFFSET, OTHER, PARABOLA, PLANE, REVOLUTION, SPHERE, TORUS | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Keep` | TOP, BOTTOM, BOTH | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Kind` | ARC, INTERSECTION, TANGENT | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Mode` | ADD, SUBTRACT, INTERSECT, REPLACE, PRIVATE | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Select` | ALL, LAST | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.SortBy` | LENGTH, RADIUS, AREA, VOLUME, DISTANCE | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Transition` | RIGHT, ROUND, TRANSFORMED | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Unit` | MICRO, MILLIMETER, CENTIMETER, METER, INCH, FOOT | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ - | :class:`~build_enums.Until` | NEXT, LAST | - +------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Align` | MIN, CENTER, MAX | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.ApproxOption` | ARC, NONE, SPLINE | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.AngularDirection` | CLOCKWISE, COUNTER_CLOCKWISE | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.CenterOf` | GEOMETRY, MASS, BOUNDING_BOX | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.FontStyle` | REGULAR, BOLD, ITALIC | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.FrameMethod` | CORRECTED, FRENET | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.GeomType` | BEZIER, BSPLINE, CIRCLE, CONE, CYLINDER, ELLIPSE, EXTRUSION, HYPERBOLA, LINE, OFFSET, OTHER, PARABOLA, PLANE, REVOLUTION, SPHERE, TORUS | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.HeadType` | CURVED, FILLETED, STRAIGHT | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Keep` | TOP, BOTTOM, BOTH | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Kind` | ARC, INTERSECTION, TANGENT | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.LengthMode` | DIAGONAL, HORIZONTAL, VERTICAL | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.MeshType` | OTHER, MODEL, SUPPORT, SOLIDSUPPORT | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Mode` | ADD, SUBTRACT, INTERSECT, REPLACE, PRIVATE | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.NumberDisplay` | DECIMAL, FRACTION | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.PageSize` | A0, A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, LEDGER, LEGAL, LETTER | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.PositionMode` | LENGTH, PARAMETER | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Select` | ALL, LAST | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Side` | BOTH, LEFT, RIGHT | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.SortBy` | LENGTH, RADIUS, AREA, VOLUME, DISTANCE | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Transition` | RIGHT, ROUND, TRANSFORMED | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Unit` | MC, MM, CM, M, IN, FT | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ + | :class:`~build_enums.Until` | FIRST, LAST, NEXT, PREVIOUS | + +----------------------------------------+-----------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/conf.py b/docs/conf.py index dab5a6df..09921cef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,7 +42,8 @@ extensions = [ "sphinx.ext.napoleon", "sphinx.ext.autodoc", - "sphinx_autodoc_typehints", +# "sphinx_autodoc_typehints", + "sphinx.ext.autodoc.typehints", "sphinx.ext.doctest", "sphinx.ext.graphviz", "sphinx.ext.inheritance_diagram", @@ -75,6 +76,7 @@ "members": True, "undoc-members": True, "member-order": "alphabetical", + "show-inheriance" : False } # autodoc_mock_imports = ["OCP"] diff --git a/docs/custom.rst b/docs/custom.rst deleted file mode 100644 index 164adeec..00000000 --- a/docs/custom.rst +++ /dev/null @@ -1,3 +0,0 @@ -################################### -Custom Builder Objects & Operations -################################### diff --git a/docs/debugging_logging.rst b/docs/debugging_logging.rst index 7e6a235c..3e27acdc 100644 --- a/docs/debugging_logging.rst +++ b/docs/debugging_logging.rst @@ -1,3 +1,97 @@ ################### Debugging & Logging ################### + +Debugging problems with your build123d design involves the same techniques +one would use to debug any Python source code; however, there are some +specific techniques that might be of assistance. The following sections +describe these techniques. + +*************** +Python Debugger +*************** + +Many Python IDEs have step by step debugging systems that can be used to +walk through your code monitoring its operation with full visibility +of all Python objects. Here is a screenshot of the Visual Studio Code +debugger in action: + +.. image:: assets/VSC_debugger.png + :align: center + +This shows that a break-point has been encountered and the code operation +has been stopped. From here all of the Python variables are visible and +the system is waiting on input from the user on how to proceed. One can +enter the code that assigns ``top_face`` by pressing the down arrow button +on the top right. Following code execution like this is a very powerful +debug technique. + +******* +Logging +******* + +Build123d support standard python logging and generates its own log stream. +If one is using **cq-editor** as a display system there is a built in +``Log viewer`` tab that shows the current log stream - here is an example +of a log stream: + +.. code-block:: bash + + [18:43:44.678646] INFO: Entering BuildPart with mode=Mode.ADD which is in different scope as parent + [18:43:44.679233] INFO: WorkplaneList is pushing 1 workplanes: [Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))] + [18:43:44.679888] INFO: LocationList is pushing 1 points: [(p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00))] + [18:43:44.681751] INFO: BuildPart context requested by Box + [18:43:44.685950] INFO: Completed integrating 1 object(s) into part with Mode=Mode.ADD + [18:43:44.690072] INFO: GridLocations is pushing 4 points: [(p=(-30.00, -20.00, 0.00), o=(-0.00, 0.00, -0.00)), (p=(-30.00, 20.00, 0.00), o=(-0.00, 0.00, -0.00)), (p=(30.00, -20.00, 0.00), o=(-0.00, 0.00, -0.00)), (p=(30.00, 20.00, 0.00), o=(-0.00, 0.00, -0.00))] + [18:43:44.691604] INFO: BuildPart context requested by Hole + [18:43:44.724628] INFO: Completed integrating 4 object(s) into part with Mode=Mode.SUBTRACT + [18:43:44.728681] INFO: GridLocations is popping 4 points + [18:43:44.747358] INFO: BuildPart context requested by chamfer + [18:43:44.762429] INFO: Completed integrating 1 object(s) into part with Mode=Mode.REPLACE + [18:43:44.765380] INFO: LocationList is popping 1 points + [18:43:44.766106] INFO: WorkplaneList is popping 1 workplanes + [18:43:44.766729] INFO: Exiting BuildPart + +The build123d logger is defined by: + +.. code-block:: python + + logging.getLogger("build123d").addHandler(logging.NullHandler()) + logger = logging.getLogger("build123d") + +To export logs to a file, the following configuration is recommended: + +.. code-block:: python + + logging.basicConfig( + filename="myapp.log", + level=logging.INFO, + format="%(name)s-%(levelname)s %(asctime)s - [%(filename)s:%(lineno)s - \ + %(funcName)20s() ] - %(message)s", + ) + +Logs can be easily placed in your code - here is an example: + +.. code-block:: python + + logger.info("Exiting %s", type(self).__name__) + + +******** +Printing +******** + +Sometimes the best debugging aid is just placing a print statement in your code. Many +of the build123d classes are setup to provide useful information beyond their class and +location in memory, as follows: + +.. code-block:: python + + plane = Plane.XY.offset(1) + print(f"{plane=}") + +.. code-block:: bash + + plane=Plane(o=(0.00, 0.00, 1.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00)) + +which shows the origin, x direction, and z direction of the plane. diff --git a/docs/direct_api_reference.rst b/docs/direct_api_reference.rst index 59caee1f..94bcf72a 100644 --- a/docs/direct_api_reference.rst +++ b/docs/direct_api_reference.rst @@ -24,15 +24,21 @@ CAD objects described in the following section are frequently of these types. .. py:module:: geometry .. autoclass:: Axis + :special-members: __copy__,__deepcopy__, __neg__ .. autoclass:: BoundBox .. autoclass:: Color + :special-members: __copy__,__deepcopy__ .. autoclass:: Location + :special-members: __copy__,__deepcopy__, __mul__, __pow__, __eq__, __neg__ .. autoclass:: Pos .. autoclass:: Rot .. autoclass:: Matrix + :special-members: __copy__,__deepcopy__ .. autoclass:: Plane + :special-members: __copy__,__deepcopy__, __eq__, __ne__, __neg__, __mul__ .. autoclass:: Rotation .. autoclass:: Vector + :special-members: __add__, __sub__, __mul__, __truediv__, __rmul__, __neg__, __abs__, __eq__, __copy__, __deepcopy__ ******************* Topological Objects @@ -53,14 +59,24 @@ Note that a :class:`~topology.Compound` may be contain only 1D, 2D (:class:`~top .. autoclass:: Compound .. autoclass:: Edge .. autoclass:: Face + :special-members: __neg__ .. autoclass:: Mixin1D + :special-members: __matmul__, __mod__ .. autoclass:: Mixin3D .. autoclass:: Shape + :special-members: __add__, __sub__, __and__, __rmul__, __eq__, __copy__, __deepcopy__, __hash__ .. autoclass:: ShapeList + :special-members: __gt__, __lt__, __rshift__, __lshift__, __or__, __and__, __sub__, __getitem__ .. autoclass:: Shell .. autoclass:: Solid .. autoclass:: Wire .. autoclass:: Vertex + :special-members: __add__, __sub__ +.. autoclass:: Curve + :special-members: __matmul__, __mod__ +.. autoclass:: Part +.. autoclass:: Sketch + ************* Import/Export @@ -70,42 +86,38 @@ Methods and functions specific to exporting and importing build123d objects are .. py:module:: topology :noindex: -.. automethod:: Shape.export_3mf - :noindex: .. automethod:: Shape.export_brep :noindex: -.. automethod:: Shape.export_dxf - :noindex: .. automethod:: Shape.export_stl :noindex: .. automethod:: Shape.export_step :noindex: .. automethod:: Shape.export_stl :noindex: -.. automethod:: Shape.export_svg - :noindex: .. py:module:: importers + :noindex: .. autofunction:: import_brep + :noindex: .. autofunction:: import_step + :noindex: .. autofunction:: import_stl + :noindex: .. autofunction:: import_svg + :noindex: .. autofunction:: import_svg_as_buildline_code + :noindex: -************* -Joint Objects -************* -Joint classes which are used to position Solid and Compound objects relative to each -other are defined below. +************ +Joint Object +************ +Base Joint class which is used to position Solid and Compound objects relative to each +other are defined below. The :ref:`joints` section contains the class description of the +derived Joint classes. .. py:module:: topology :noindex: .. autoclass:: Joint -.. autoclass:: RigidJoint -.. autoclass:: RevoluteJoint -.. autoclass:: LinearJoint -.. autoclass:: CylindricalJoint -.. autoclass:: BallJoint diff --git a/docs/external.rst b/docs/external.rst index 406f0689..629c8f17 100644 --- a/docs/external.rst +++ b/docs/external.rst @@ -11,18 +11,44 @@ that extend its functionality. Editors & Viewers ***************** -cq-editor -========= - -See: `cq-editor `_ - ocp-vscode ========== See: `ocp-vscode `_ - (formerly known as cq_vscode) +Watch John Degenstein create three build123d designs being created in realtime with Visual +Studio Code and the ocp-vscode viewer in a timed event from the TooTallToby 2023 Leadership +Challenge: +`build123d May 2023 TooTallToby Leaderboard Challenge `_ + +cq-editor +========= + +See: `cq-editor `_ + + ************** Part Libraries ************** + +bd_warehouse +============ + +On-demand generation of parametric parts that seamlessly integrate into +build123d projects. + +See: `bd_warehouse `_ + + +***** +Tools +***** + +ocp-freecad-cam +=============== + +CAM for CadQuery and Build123d by leveraging FreeCAD library. Visualizes in CQ-Editor +and ocp-cad-viewer. Spiritual successor of `cq-cam `_ + +See: `ocp-freecad-cam `_ \ No newline at end of file diff --git a/docs/general_examples.py b/docs/general_examples.py index b3d39a5d..f07d84bb 100644 --- a/docs/general_examples.py +++ b/docs/general_examples.py @@ -28,30 +28,27 @@ """ from build123d import * -svg_opts = { - "width": 500, - "height": 220, - # "pixel_scale": 4, - "margin_left": 10, - "margin_top": 10, - "show_axes": False, - "show_hidden": True, -} - - -def svgout(ex_counter, width=500, height=220): - svg_opts["width"] = width - svg_opts["height"] = height - obj = globals()[f"ex{ex_counter}"] - obj.part.export_svg( - f"assets/general_ex{ex_counter}.svg", - (-100, -100, 70), - (0, 0, 1), - svg_opts=svg_opts, - ) - - -ex_counter = 1 + +def write_svg(): + """Save an image of the BuildPart object as SVG""" + global example_counter + try: + example_counter += 1 + except: + example_counter = 1 + + builder: BuildPart = BuildPart._get_context() + + visible, hidden = builder.part.project_to_viewport((-100, -100, 70)) + max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) + exporter = ExportSVG(scale=100 / max_dimension) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write(f"assets/general_ex{example_counter}.svg") + + ########################################## # 1. Simple Rectangular Plate # [Ex. 1] @@ -59,11 +56,8 @@ def svgout(ex_counter, width=500, height=220): with BuildPart() as ex1: Box(length, width, thickness) -# [Ex. 1] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 1] + write_svg() # show_object(ex1.part) @@ -77,11 +71,9 @@ def svgout(ex_counter, width=500, height=220): with BuildPart() as ex2: Box(length, width, thickness) Cylinder(radius=center_hole_dia / 2, height=thickness, mode=Mode.SUBTRACT) -# [Ex. 2] + # [Ex. 2] + write_svg() -svgout(ex_counter) - -ex_counter += 1 # show_object(ex2.part) ########################################## @@ -94,11 +86,9 @@ def svgout(ex_counter, width=500, height=220): Circle(width) Rectangle(length / 2, width / 2, mode=Mode.SUBTRACT) extrude(amount=2 * thickness) -# [Ex. 3] + # [Ex. 3] + write_svg() -svgout(ex_counter) - -ex_counter += 1 # show_object(ex3.part) ########################################## @@ -115,11 +105,9 @@ def svgout(ex_counter, width=500, height=220): l4 = Line((0.0, width), (0, 0)) make_face() extrude(amount=thickness) -# [Ex. 4] - -svgout(ex_counter) + # [Ex. 4] + write_svg() -ex_counter += 1 # show_object(ex4.part) ########################################## @@ -135,11 +123,8 @@ def svgout(ex_counter, width=500, height=220): with Locations((0, b)): Circle(d, mode=Mode.SUBTRACT) extrude(amount=c) -# [Ex. 5] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 5] + write_svg() # show_object(ex5.part) @@ -154,11 +139,8 @@ def svgout(ex_counter, width=500, height=220): with Locations((b, 0), (0, b), (-b, 0), (0, -b)): Circle(c, mode=Mode.SUBTRACT) extrude(amount=c) -# [Ex. 6] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 6] + write_svg() # show_object(ex6.part) ############################# @@ -172,11 +154,8 @@ def svgout(ex_counter, width=500, height=220): with Locations((0, 3 * c), (0, -3 * c)): RegularPolygon(radius=2 * c, side_count=6, mode=Mode.SUBTRACT) extrude(amount=c) -# [Ex. 7] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 7] + write_svg() # show_object(ex7.part) @@ -202,11 +181,8 @@ def svgout(ex_counter, width=500, height=220): mirror(ex8_ln.line, about=Plane.YZ) make_face() extrude(amount=L) -# [Ex. 8] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 8] + write_svg() # show_object(ex8.part) @@ -219,11 +195,8 @@ def svgout(ex_counter, width=500, height=220): Box(length, width, thickness) chamfer(ex9.edges().group_by(Axis.Z)[-1], length=4) fillet(ex9.edges().filter_by(Axis.Z), radius=5) -# [Ex. 9] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 9] + write_svg() # show_object(ex9.part) @@ -236,11 +209,8 @@ def svgout(ex_counter, width=500, height=220): Box(length, width, thickness) Hole(radius=width / 4) fillet(ex10.edges(Select.LAST).group_by(Axis.Z)[-1], radius=2) -# [Ex. 10] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 10] + write_svg() # show_object(ex10.part) @@ -259,11 +229,8 @@ def svgout(ex_counter, width=500, height=220): with GridLocations(length / 2, width / 2, 2, 2): RegularPolygon(radius=5, side_count=5) extrude(amount=-thickness, mode=Mode.SUBTRACT) -# [Ex. 11] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 11] + write_svg() # show_object(ex11) @@ -289,11 +256,8 @@ def svgout(ex_counter, width=500, height=220): l4 = Line((0, 0), (0, 20)) make_face() extrude(amount=10) -# [Ex. 12] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 12] + write_svg() # show_object(ex12.part) @@ -309,11 +273,8 @@ def svgout(ex_counter, width=500, height=220): CounterSinkHole(radius=b, counter_sink_radius=2 * b) with PolarLocations(radius=a, count=4, start_angle=45, angular_range=360): CounterBoreHole(radius=b, counter_bore_radius=2 * b, counter_bore_depth=b) -# [Ex. 13] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 13] + write_svg() # show_object(ex13.part) @@ -330,11 +291,8 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch(Plane.XZ) as ex14_sk: Rectangle(b, b) sweep() -# [Ex. 14] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 14] + write_svg() # show_object(ex14.part) @@ -355,11 +313,8 @@ def svgout(ex_counter, width=500, height=220): mirror(ex15_ln.line, about=Plane.YZ) make_face() extrude(amount=c) -# [Ex. 15] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 15] + write_svg() # show_object(ex15.part) @@ -384,11 +339,8 @@ def svgout(ex_counter, width=500, height=220): mirror(ex16_single.part, about=Plane.YX.offset(width)) mirror(ex16_single.part, about=Plane.YZ.offset(width)) mirror(ex16_single.part, about=Plane.YZ.offset(-width)) -# [Ex. 16] - -svgout(ex_counter, height=400) - -ex_counter += 1 + # [Ex. 16] + write_svg() # show_object(ex16.part) @@ -401,12 +353,9 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch() as ex17_sk: RegularPolygon(radius=a, side_count=5) extrude(amount=b) - mirror(ex17.part, about=Plane(ex17.faces().group_by(Axis.Y)[0][0]) -# [Ex. 17] - -svgout(ex_counter) - -ex_counter += 1 + mirror(ex17.part, about=Plane(ex17.faces().group_by(Axis.Y)[0][0])) + # [Ex. 17] + write_svg() # show_object(ex17.part) @@ -424,11 +373,8 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch(ex18.faces().sort_by(Axis.Z)[-1]): Rectangle(2 * b, 2 * b) extrude(amount=-thickness, mode=Mode.SUBTRACT) -# [Ex. 18] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 18] + write_svg() # show_object(ex18.part) @@ -449,11 +395,8 @@ def svgout(ex_counter, width=500, height=220): with Locations((vtx.X, vtx.Y), (vtx2.X, vtx2.Y)): Circle(radius=length / 8) extrude(amount=-thickness, mode=Mode.SUBTRACT) -# [Ex. 19] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 19] + write_svg() # show_object(ex19.part) @@ -468,11 +411,8 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch(plane.offset(2 * thickness)): Circle(width / 3) extrude(amount=width) -# [Ex. 20] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 20] + write_svg() # show_object(ex20.part) @@ -488,11 +428,8 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch(Plane(origin=ex21.part.center(), z_dir=(-1, 0, 0))): Circle(width / 2) extrude(amount=length) -# [Ex. 21] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 21] + write_svg() # show_object(ex21.part) @@ -508,11 +445,8 @@ def svgout(ex_counter, width=500, height=220): with GridLocations(length / 4, width / 4, 2, 2): Circle(thickness / 4) extrude(amount=-100, both=True, mode=Mode.SUBTRACT) -# [Ex. 22] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 22] + write_svg() # show_object(ex22.part) @@ -538,11 +472,8 @@ def svgout(ex_counter, width=500, height=220): Circle(25) split(bisect_by=Plane.ZY) revolve(axis=Axis.Z) -# [Ex. 23] - -svgout(ex_counter, height=300) - -ex_counter += 1 + # [Ex. 23] + write_svg() # show_object(ex23.part) @@ -558,11 +489,8 @@ def svgout(ex_counter, width=500, height=220): with BuildSketch(ex24_sk.faces()[0].offset(length / 2)) as ex24_sk2: Rectangle(length / 6, width / 6) loft() -# [Ex. 24] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 24] + write_svg() # show_object(ex24.part) @@ -581,11 +509,8 @@ def svgout(ex_counter, width=500, height=220): RegularPolygon(radius=rad, side_count=5) offset(amount=offs, kind=Kind.INTERSECTION) extrude(amount=1) -# [Ex. 25] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 25] + write_svg() # show_object(ex25.part) @@ -598,11 +523,8 @@ def svgout(ex_counter, width=500, height=220): Box(length, width, thickness) topf = ex26.faces().sort_by(Axis.Z)[-1] offset(amount=-wall, openings=topf) -# [Ex. 26] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 26] + write_svg() # show_object(ex26.part) @@ -617,11 +539,8 @@ def svgout(ex_counter, width=500, height=220): Circle(width / 4) extrude(amount=-thickness, mode=Mode.SUBTRACT) split(bisect_by=Plane(ex27.faces().sort_by(Axis.Y)[-1]).offset(-width / 2)) -# [Ex. 27] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 27] + write_svg() # show_object(ex27.part) @@ -639,11 +558,8 @@ def svgout(ex_counter, width=500, height=220): for face in midfaces: with Locations(face): Hole(thickness / 2) -# [Ex. 28] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 28] + write_svg() # show_object(ex28.part) @@ -667,11 +583,8 @@ def svgout(ex_counter, width=500, height=220): extrude(amount=n) necktopf = ex29.faces().sort_by(Axis.Z)[-1] offset(ex29.solids()[0], amount=-b, openings=necktopf) -# [Ex. 29] - -svgout(ex_counter, height=400) - -ex_counter += 1 + # [Ex. 29] + write_svg() # show_object(ex29.part) @@ -705,11 +618,8 @@ def svgout(ex_counter, width=500, height=220): l1 = Bezier(*pts, weights=wts) make_face() extrude(amount=10) -# [Ex. 30] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 30] + write_svg() # show_object(ex30.part) @@ -726,11 +636,8 @@ def svgout(ex_counter, width=500, height=220): RegularPolygon(b, 4) RegularPolygon(3 * b, 6, rotation=30) extrude(amount=c) -# [Ex. 31] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 31] + write_svg() # show_object(ex31.part) @@ -747,11 +654,8 @@ def svgout(ex_counter, width=500, height=220): for idx, obj in enumerate(ex32_sk.sketch.faces()): add(obj) extrude(amount=c + 3 * idx) -# [Ex. 32] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 32] + write_svg() # show_object(ex32.part) @@ -776,11 +680,8 @@ def square(rad, loc): for idx, obj in enumerate(ex33_sk.sketch.faces()): add(obj) extrude(amount=c + 2 * idx) -# [Ex. 33] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 33] + write_svg() # show_object(ex33.part) @@ -798,11 +699,8 @@ def square(rad, loc): with BuildSketch(topf) as ex34_sk2: Text("World", font_size=fontsz, align=(Align.CENTER, Align.MAX)) extrude(amount=-fontht, mode=Mode.SUBTRACT) -# [Ex. 34] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 34] + write_svg() # show_object(ex34.part) @@ -823,11 +721,8 @@ def square(rad, loc): RadiusArc((0, -width / 2), (width / 2, 0), radius=-width / 2) SlotArc(arc=ex35_ln2.edges()[0], height=thickness, rotation=0) extrude(amount=-thickness, mode=Mode.SUBTRACT) -# [Ex. 35] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 35] + write_svg() # show_object(ex35.part) @@ -844,10 +739,7 @@ def square(rad, loc): with BuildSketch() as ex36_sk2: Rectangle(rad, rev) extrude(until=Until.NEXT) -# [Ex. 36] - -svgout(ex_counter) - -ex_counter += 1 + # [Ex. 36] + write_svg() # show_object(ex36.part) diff --git a/docs/general_examples_algebra.py b/docs/general_examples_algebra.py index e3a18e39..f6038547 100644 --- a/docs/general_examples_algebra.py +++ b/docs/general_examples_algebra.py @@ -28,31 +28,7 @@ """ from build123d import * -svg_opts = { - "width": 500, - "height": 300, - # "pixel_scale": 4, - "margin_left": 10, - "margin_top": 10, - "show_axes": False, - "show_hidden": True, -} - - -def svgout(ex_counter, width=500, height=220): - return # no need to write svg - # svg_opts["width"] = width - # svg_opts["height"] = height - # obj = globals()[f"ex{ex_counter}"] - # obj.part.export_svg( - # f"assets/general_ex{ex_counter}.svg", - # (-100, -100, 70), - # (0, 0, 1), - # svg_opts=svg_opts, - # ) - - -ex_counter = 1 + ########################################## # 1. Simple Rectangular Plate # [Ex. 1] @@ -60,11 +36,6 @@ def svgout(ex_counter, width=500, height=220): ex1 = Box(length, width, thickness) # [Ex. 1] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex1) @@ -77,10 +48,6 @@ def svgout(ex_counter, width=500, height=220): ex2 = Box(length, width, thickness) ex2 -= Cylinder(center_hole_dia / 2, height=thickness) # [Ex. 2] - -svgout(ex_counter) - -ex_counter += 1 # show_object(ex2) ########################################## @@ -91,10 +58,6 @@ def svgout(ex_counter, width=500, height=220): sk3 = Circle(width) - Rectangle(length / 2, width / 2) ex3 = extrude(sk3, amount=2 * thickness) # [Ex. 3] - -svgout(ex_counter) - -ex_counter += 1 # show_object(ex3) ########################################## @@ -111,10 +74,6 @@ def svgout(ex_counter, width=500, height=220): sk4 = make_face(lines) ex4 = extrude(sk4, thickness) # [Ex. 4] - -svgout(ex_counter) - -ex_counter += 1 # show_object(ex4) ########################################## @@ -125,11 +84,6 @@ def svgout(ex_counter, width=500, height=220): sk5 = Circle(a) - Pos(b, 0.0) * Rectangle(c, c) - Pos(0.0, b) * Circle(d) ex5 = extrude(sk5, c) # [Ex. 5] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex5) ########################################## @@ -140,11 +94,6 @@ def svgout(ex_counter, width=500, height=220): sk6 = [loc * Circle(c) for loc in Locations((b, 0), (0, b), (-b, 0), (0, -b))] ex6 = extrude(Circle(a) - sk6, c) # [Ex. 6] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex6) ################################# # Polygons @@ -158,11 +107,6 @@ def svgout(ex_counter, width=500, height=220): sk7 = Rectangle(a, b) - polygons ex7 = extrude(sk7, amount=c) # [Ex. 7] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex7) ########################################## @@ -186,11 +130,6 @@ def svgout(ex_counter, width=500, height=220): sk8 = make_face(Plane.YZ * ln) ex8 = extrude(sk8, -L).clean() # [Ex. 8] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex8) ########################################## @@ -202,11 +141,6 @@ def svgout(ex_counter, width=500, height=220): ex9 = chamfer(ex9.edges().group_by(Axis.Z)[-1], length=4) ex9 = fillet(ex9.edges().filter_by(Axis.Z), radius=5) # [Ex. 9] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex9) ########################################## @@ -219,11 +153,6 @@ def svgout(ex_counter, width=500, height=220): last_edges = ex10.edges() - snapshot ex10 = fillet(last_edges.group_by(Axis.Z)[-1], 2) # [Ex. 10] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex10) ########################################## @@ -245,11 +174,6 @@ def svgout(ex_counter, width=500, height=220): ] ex11 -= extrude(polygons, -thickness) # [Ex. 11] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex11) ########################################## @@ -273,11 +197,6 @@ def svgout(ex_counter, width=500, height=220): sk12 = make_face([l1, l2, l3, l4]) ex12 = extrude(sk12, 10) # [Ex. 12] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex12) @@ -302,11 +221,6 @@ def svgout(ex_counter, width=500, height=220): ) ) # [Ex. 13] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex13) ########################################## @@ -322,11 +236,6 @@ def svgout(ex_counter, width=500, height=220): sk14 = Plane.XZ * Rectangle(b, b) ex14 = sweep(sk14, path=ex14_ln.wires()[0]) # [Ex. 14] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex14) @@ -346,11 +255,6 @@ def svgout(ex_counter, width=500, height=220): sk15 = make_face(ln) ex15 = extrude(sk15, c) # [Ex. 15] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex15) ########################################## @@ -376,11 +280,6 @@ def svgout(ex_counter, width=500, height=220): objs = [mirror(ex16_single, plane) for plane in planes] ex16 = ex16_single + objs # [Ex. 16] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex16) ########################################## @@ -392,11 +291,6 @@ def svgout(ex_counter, width=500, height=220): ex17 = extrude(sk17, amount=b) ex17 += mirror(ex17, Plane(ex17.faces().sort_by(Axis.Y).first)) # [Ex. 17] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex17) ########################################## @@ -413,11 +307,6 @@ def svgout(ex_counter, width=500, height=220): sk18 = Plane(ex18.faces().sort_by().first) * Rectangle(2 * b, 2 * b) ex18 -= extrude(sk18, -thickness) # [Ex. 18] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex18) ########################################## @@ -440,11 +329,6 @@ def svgout(ex_counter, width=500, height=220): ex19 -= extrude(ex19_sk2, thickness) # [Ex. 19] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex19) ########################################## @@ -458,11 +342,6 @@ def svgout(ex_counter, width=500, height=220): sk20 = plane * Circle(width / 3) ex20 += extrude(sk20, width) # [Ex. 20] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex20) ########################################## @@ -474,11 +353,6 @@ def svgout(ex_counter, width=500, height=220): plane = Plane(origin=ex21.center(), z_dir=(-1, 0, 0)) ex21 += plane * extrude(Circle(width / 2), length) # [Ex. 21] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex21) ########################################## @@ -495,11 +369,6 @@ def svgout(ex_counter, width=500, height=220): ] ex22 -= extrude(holes, -100, both=True) # [Ex. 22] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex22) ########################################## @@ -523,11 +392,6 @@ def svgout(ex_counter, width=500, height=220): ex23 = revolve(sk23, Axis.Z) # [Ex. 23] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex23) ########################################## @@ -545,11 +409,6 @@ def svgout(ex_counter, width=500, height=220): ex24 += loft(faces) # [Ex. 24] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex24) ########################################## @@ -566,11 +425,6 @@ def svgout(ex_counter, width=500, height=220): sk25 = Sketch() + [sk25_1, sk25_2, sk25_3] ex25 = extrude(sk25, 1) # [Ex. 25] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex25) ########################################## @@ -582,11 +436,6 @@ def svgout(ex_counter, width=500, height=220): topf = ex26.faces().sort_by().last ex26 = offset(ex26, amount=-wall, openings=topf) # [Ex. 26] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex26) ########################################## @@ -599,11 +448,6 @@ def svgout(ex_counter, width=500, height=220): ex27 -= extrude(sk27, -thickness) ex27 = split(ex27, Plane(ex27.faces().sort_by(Axis.Y).last).offset(-width / 2)) # [Ex. 27] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex27) ########################################## @@ -617,11 +461,6 @@ def svgout(ex_counter, width=500, height=220): for p in [Plane(face) for face in tmp28.faces().group_by(Axis.Z)[1]]: ex28 -= p * Hole(thickness / 2, depth=width) # [Ex. 28] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex28) ########################################## @@ -643,11 +482,6 @@ def svgout(ex_counter, width=500, height=220): necktopf = ex29.faces().sort_by().last ex29 = offset(ex29, -b, openings=necktopf) # [Ex. 29] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex29) ########################################## @@ -677,11 +511,6 @@ def svgout(ex_counter, width=500, height=220): ex30_sk = make_face(ex30_ln) ex30 = extrude(ex30_sk, -10) # [Ex. 30] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex30) ########################################## @@ -695,11 +524,6 @@ def svgout(ex_counter, width=500, height=220): ) ex31 = extrude(ex31, 3) # [Ex. 31] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex31) ########################################## @@ -711,11 +535,6 @@ def svgout(ex_counter, width=500, height=220): ex32_sk += PolarLocations(a / 2, 6) * RegularPolygon(b, 4) ex32 = Part() + [extrude(obj, c + 3 * idx) for idx, obj in enumerate(ex32_sk.faces())] # [Ex. 32] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex32) ########################################## @@ -723,19 +542,16 @@ def svgout(ex_counter, width=500, height=220): # [Ex. 33] a, b, c = 80.0, 5.0, 1.0 + def square(rad, loc): return loc * RegularPolygon(rad, 4) + ex33 = Part() + [ extrude(square(b + 2 * i, loc), c + 2 * i) for i, loc in enumerate(PolarLocations(a / 2, 6)) ] # [Ex. 33] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex33) ########################################## @@ -750,11 +566,6 @@ def square(rad, loc): ex34_sk2 = plane * Text("World", font_size=fontsz, align=(Align.CENTER, Align.MAX)) ex34 -= extrude(ex34_sk2, amount=-fontht) # [Ex. 34] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex34) ########################################## @@ -771,11 +582,6 @@ def square(rad, loc): ex35_sk += SlotArc(arc=ex35_ln2.edges()[0], height=thickness) ex35 -= extrude(plane * ex35_sk, -thickness) # [Ex. 35] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex35) ########################################## @@ -788,9 +594,4 @@ def square(rad, loc): ex36_sk2 = Rectangle(rad, rev) ex36 += extrude(ex36_sk2, until=Until.NEXT, target=ex36) # [Ex. 36] - -svgout(ex_counter) - -ex_counter += 1 - # show_object(ex36) diff --git a/docs/import_export.rst b/docs/import_export.rst new file mode 100644 index 00000000..c96f8a1f --- /dev/null +++ b/docs/import_export.rst @@ -0,0 +1,277 @@ +############# +Import/Export +############# + +Methods and functions specific to exporting and importing build123d objects are defined below. + +For example: + +.. code-block:: python + + with BuildPart() as box_builder: + Box(1, 1, 1) + box_builder.part.export_step("box.step") + +File Formats +============ + +3MF +--- + +The 3MF (3D Manufacturing Format) file format is a versatile and modern standard +for representing 3D models used in additive manufacturing, 3D printing, and other +applications. Developed by the 3MF Consortium, it aims to overcome the limitations +of traditional 3D file formats by providing a more efficient and feature-rich solution. +The 3MF format supports various advanced features like color information, texture mapping, +multi-material definitions, and precise geometry representation, enabling seamless +communication between design software, 3D printers, and other manufacturing devices. +Its open and extensible nature makes it an ideal choice for exchanging complex 3D data +in a compact and interoperable manner. + +BREP +---- + +The BREP (Boundary Representation) file format is a widely used data format in +computer-aided design (CAD) and computer-aided engineering (CAE) applications. +BREP represents 3D geometry using topological entities like vertices, edges, +and faces, along with their connectivity information. It provides a precise +and comprehensive representation of complex 3D models, making it suitable for +advanced modeling and analysis tasks. BREP files are widely supported by various +CAD software, enabling seamless data exchange between different systems. Its ability +to represent both geometric shapes and their topological relationships makes it a +fundamental format for storing and sharing detailed 3D models. + +DXF +--- + +The DXF (Drawing Exchange Format) file format is a widely used standard for +representing 2D and 3D drawings, primarily used in computer-aided design (CAD) +applications. Developed by Autodesk, DXF files store graphical and geometric data, +such as lines, arcs, circles, and text, as well as information about layers, colors, +and line weights. Due to its popularity, DXF files can be easily exchanged and +shared between different CAD software. The format's simplicity and human-readable +structure make it a versatile choice for sharing designs, drawings, and models +across various CAD platforms, facilitating seamless collaboration in engineering +and architectural projects. + +STL +--- + +The STL (STereoLithography) file format is a widely used file format in 3D printing +and computer-aided design (CAD) applications. It represents 3D geometry using +triangular facets to approximate the surface of a 3D model. STL files are widely +supported and can store both the geometry and color information of the model. +They are used for rapid prototyping and 3D printing, as they provide a simple and +efficient way to represent complex 3D objects. The format's popularity stems from +its ease of use, platform independence, and ability to accurately describe the +surface of intricate 3D models with a minimal file size. + +STEP +---- + +The STEP (Standard for the Exchange of Product model data) file format is a widely +used standard for representing 3D product and manufacturing data in computer-aided +design (CAD) and computer-aided engineering (CAE) applications. It is an ISO standard +(ISO 10303) and supports the representation of complex 3D geometry, product structure, +and metadata. STEP files store information in a neutral and standardized format, +making them highly interoperable across different CAD/CAM software systems. They +enable seamless data exchange between various engineering disciplines, facilitating +collaboration and data integration throughout the entire product development and +manufacturing process. + +SVG +--- + +The SVG (Scalable Vector Graphics) file format is an XML-based standard used for +describing 2D vector graphics. It is widely supported and can be displayed in modern +web browsers, making it suitable for web-based graphics and interactive applications. +SVG files define shapes, paths, text, and images using mathematical equations, +allowing for smooth scalability without loss of quality. The format is ideal for +logos, icons, illustrations, and other graphics that require resolution independence. +SVG files are also easily editable in text editors or vector graphic software, making +them a popular choice for designers and developers seeking flexible and versatile graphic +representation. + + +2D Exporters +============ + +Exports to DXF (Drawing Exchange Format) and SVG (Scalable Vector Graphics) +are provided by the 2D Exporters: ExportDXF and ExportSVG classes. + +DXF is a widely used file format for exchanging CAD (Computer-Aided Design) +data between different software applications. SVG is a widely used vector graphics +format that is supported by web browsers and various graphic editors. + +The core concept to these classes is the creation of a DXF/SVG document with +specific properties followed by the addition of layers and shapes to the documents. +Once all of the layers and shapes have been added, the document can be written +to a file. + +3D to 2D Projection +------------------- + +There are a couple ways to generate a 2D drawing of a 3D part: + +* Generate a section: The :func:`~operations_part.section` operation can be used to + create a 2D cross section of a 3D part at a given plane. +* Generate a projection: The :meth:`~topology.Shape.project_to_viewport` method can be + used to create a 2D projection of a 3D scene. Similar to a camera, the ``viewport_origin`` + defines the location of camera, the ``viewport_up`` defines the orientation of the camera, + and the ``look_at`` parameter defined where the camera is pointed. By default, + ``viewport_up`` is the positive z axis and ``look_up`` is the center of the shape. The + return value is a tuple of lists of edges, the first the visible edges and the second + the hidden edges. + +Each of these Edges and Faces can be assigned different line color/types and fill colors +as described below (as ``project_to_viewport`` only generates Edges, fill doesn't apply). +The shapes generated from the above steps are to be added as shapes +in one of the exporters described below and written as either a DXF or SVG file as shown +in this example: + +.. code-block:: python + + view_port_origin=(-100, -50, 30) + visible, hidden = part.project_to_viewport(view_port_origin) + max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) + exporter = ExportSVG(scale=100 / max_dimension) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("part_projection.svg") + +LineType +-------- + +ANSI (American National Standards Institute) and ISO (International Organization for +Standardization) standards both define line types in drawings used in DXF and SVG +exported drawings: + +* ANSI Standards: + * ANSI/ASME Y14.2 - "Line Conventions and Lettering" is the standard that defines + line types, line weights, and line usage in engineering drawings in the United States. + +* ISO Standards: + * ISO 128 - "Technical drawings -- General principles of presentation" is the ISO + standard that covers the general principles of technical drawing presentation, + including line types and line conventions. + * ISO 13567 - "Technical product documentation (TPD) -- Organization and naming of + layers for CAD" provides guidelines for the organization and naming of layers in + Computer-Aided Design (CAD) systems, which may include line type information. + +These standards help ensure consistency and clarity in technical drawings, making it +easier for engineers, designers, and manufacturers to communicate and interpret the +information presented in the drawings. + +The line types used by the 2D Exporters are defined by the :class:`~exporters.LineType` +Enum and are shown in the following diagram: + +.. image:: assets/line_types.svg + :align: center + + +ExportDXF +--------- + +.. autoclass:: exporters.ExportDXF + :noindex: + +ExportSVG +--------- + +.. autoclass:: exporters.ExportSVG + :noindex: + +3D Exporters +============ + + +.. automethod:: topology.Shape.export_brep + :noindex: + +.. automethod:: topology.Shape.export_step + :noindex: + +.. automethod:: topology.Shape.export_stl + :noindex: + +3D Mesh Export +-------------- + +Both 3MF and STL export (and import) are provided with the :class:`~mesher.Mesher` class. +As mentioned above the 3MF format provides is feature-rich and therefore has a slightly +more complex API than the simple Shape exporters. + +For example: + +.. code-block:: python + + # Create the shapes and assign attributes + blue_shape = Solid.make_cone(20, 0, 50) + blue_shape.color = Color("blue") + blue_shape.label = "blue" + blue_uuid = uuid.uuid1() + red_shape = Solid.make_cylinder(5, 50).move(Location((0, -30, 0))) + red_shape.color = Color("red") + red_shape.label = "red" + + # Create a Mesher instance as an exporter, add shapes and write + exporter = Mesher() + exporter.add_shape(blue_shape, part_number="blue-1234-5", uuid_value=blue_uuid) + exporter.add_shape(red_shape) + exporter.add_meta_data( + name_space="custom", + name="test_meta_data", + value="hello world", + metadata_type="str", + must_preserve=False, + ) + exporter.add_code_to_metadata() + exporter.write("example.3mf") + exporter.write("example.stl") + +.. autoclass:: mesher.Mesher + +2D Importers +============ +.. py:module:: importers + +.. autofunction:: import_svg +.. autofunction:: import_svg_as_buildline_code + +3D Importers +============ + +.. autofunction:: import_brep +.. autofunction:: import_step +.. autofunction:: import_stl + +3D Mesh Import +-------------- + +Both 3MF and STL import (and export) are provided with the :class:`~mesher.Mesher` class. + +For example: + +.. code-block:: python + + importer = Mesher() + cone, cyl = importer.read("example.3mf") + print( + f"{importer.mesh_count=}, {importer.vertex_counts=}, {importer.triangle_counts=}" + ) + print(f"Imported model unit: {importer.model_unit}") + print(f"{cone.label=}") + print(f"{cone.color.to_tuple()=}") + print(f"{cyl.label=}") + print(f"{cyl.color.to_tuple()=}") + +.. code-block:: none + + importer.mesh_count=2, importer.vertex_counts=[66, 52], importer.triangle_counts=[128, 100] + Imported model unit: Unit.MM + cone.label='blue' + cone.color.to_tuple()=(0.0, 0.0, 1.0, 1.0) + cyl.label='red' + cyl.color.to_tuple()=(1.0, 0.0, 0.0, 1.0) diff --git a/docs/index.rst b/docs/index.rst index 47a5c7aa..5cae1aa7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,9 +96,17 @@ Table Of Contents introduction.rst installation.rst + key_concepts.rst + key_concepts_algebra.rst introductory_examples.rst tutorials.rst + objects.rst + operations.rst builders.rst + joints.rst + assemblies.rst + tips.rst + import_export.rst advanced.rst cheat_sheet.rst external.rst diff --git a/docs/installation.rst b/docs/installation.rst index 2f263585..93ed90fa 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -27,7 +27,7 @@ upgraded pip to the latest version with the following command: If you use `poetry `_ to install build123d, you might need to specify the branch that is used for git-based installs ; until quite recently, poetry used to checkout the -`master` branch when none was specified, and this fails on build123d that uses a `dev` branch. +`master` branch when none was specified, and this fails on build123d that uses a `dev` branch. Pip does not suffer from this issue because it correctly fetches the repository default branch. @@ -43,15 +43,15 @@ recent versions of poetry. Development install of build123d: ---------------------------------------------- -**Warning**: it is highly recommended to upgrade pip to the latest version before installing +**Warning**: it is highly recommended to upgrade pip to the latest version before installing build123d, especially in development mode. This can be done with the following command: .. doctest:: >>> python3 -m pip install --upgrade pip -Once pip is up-to-date, you can install build123d -`in development mode `_ +Once pip is up-to-date, you can install build123d +`in development mode `_ with the following commands: .. doctest:: @@ -85,6 +85,33 @@ Which should return something similar to: ├── Face at 0x165e88218f0, Center(0.5, 1.0, 0.0) └── Face at 0x165eb21ee70, Center(0.5, 1.0, 3.0) +Special notes on Apple Silicon installs +---------------------------------------------- + +Due to some dependencies not being available via pip, there is a bit of a hacky work around for Apple Silicon installs (M1 or M2 ARM64 architecture machines - if you aren't sure, try `uname -p` in a terminal and see if it returns arm). Specifically the cadquery-ocp dependency fails to resolve at install time. The error looks something like this: + +.. doctest:: + + └[~]> python3 -m pip install git+https://github.com/gumyr/build123d + Collecting git+https://github.com/gumyr/build123d + ... + INFO: pip is looking at multiple versions of build123d to determine which version is compatible with other requirements. This could take a while. + ERROR: Could not find a version that satisfies the requirement cadquery-ocp~=7.7.1 (from build123d) (from versions: none) + ERROR: No matching distribution found for cadquery-ocp~=7.7.1 + +A procedure for avoiding this issue is to install in a conda environment, which does have the missing dependency (substituting for the environment name you want to use for this install): + +.. doctest:: + + conda create -n python=3.10 + conda activate + conda install -c cadquery -c conda-forge cadquery=master + pip install --no-deps git+https://github.com/gumyr/build123d svgwrite svgpathtools anytree scipy ipython + pip install --no-deps ocp_tessellate webcolors==1.12 numpy numpy-quaternion cachetools==5.2.0 + pip install --no-deps ocp_vscode requests orjson urllib3 certifi numpy-stl + +`You can track the issue here `_ + Adding a nicer GUI ---------------------------------------------- diff --git a/docs/introduction.rst b/docs/introduction.rst index b5fd003c..486bb877 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -270,17 +270,6 @@ customized to match the look and feel of your company's documentation. They also provide multiple output formats, support for multiple languages and can be integrated with code management tools. -**************************************** -Key Concepts (context mode) -**************************************** - -.. include:: key_concepts.rst - -**************************************** -Key Concepts (algebra mode) -**************************************** - -.. include:: key_concepts_algebra.rst ************************ Advantages Over CadQuery diff --git a/docs/introductory_examples.rst b/docs/introductory_examples.rst index ceeca359..3f7a3332 100644 --- a/docs/introductory_examples.rst +++ b/docs/introductory_examples.rst @@ -14,7 +14,7 @@ They are organized from simple to complex, so working through them in order is t 2. If you are using Build123d *context mode*, - in *CQ-editor* add e.g. ``show_object(ex15.part)``, ``show_object(ex15.sketch)`` or ``show_object(ex15.line)`` to view parts, sketches or lines. - - in *ocp_vscode* simply use ``show_object(ex15)`` for parts, sketches and curves. + - in *ocp_vscode* simply use e.g. ``show_object(ex15)`` for parts, sketches and curves. 3. If you are using Build123d *algebra mode*, add the line e.g. ``show_object(ex15)`` for parts, sketches and curves at the end. 4. If you want to save your resulting file as an STL, it is currently best to use e.g. ``ex15.part.export_stl("file.stl")``. @@ -22,6 +22,8 @@ They are organized from simple to complex, so working through them in order is t .. contents:: List of Examples :backlinks: entry +.. _ex 1: + 1. Simple Rectangular Plate --------------------------------------------------- @@ -43,6 +45,8 @@ Just about the simplest possible example, a rectangular :class:`~objects_part.Bo :end-before: [Ex. 1] +.. _ex 2: + 2. Plate with Hole --------------------------------------------------- @@ -72,6 +76,8 @@ A rectangular box, but with a hole added. :end-before: [Ex. 2] +.. _ex 3: + 3. An extruded prismatic solid --------------------------------------------------- @@ -100,6 +106,8 @@ Build a prismatic solid using extrusion. :end-before: [Ex. 3] +.. _ex 4: + 4. Building Profiles using lines and arcs --------------------------------------------------- @@ -134,6 +142,8 @@ variables for the line segments, but it will be useful in a later example. Note that to build a closed face it requires line segments that form a closed shape. +.. _ex 5: + 5. Moving the current working point --------------------------------------------------- @@ -161,6 +171,8 @@ Note that to build a closed face it requires line segments that form a closed sh :end-before: [Ex. 5] +.. _ex 6: + 6. Using Point Lists --------------------------------------------------- @@ -191,6 +203,8 @@ Sometimes you need to create a number of features at various :end-before: [Ex. 6] +.. _ex 7: + 7. Polygons --------------------------------------------------- @@ -216,6 +230,8 @@ Sometimes you need to create a number of features at various :end-before: [Ex. 7] +.. _ex 8: + 8. Polylines --------------------------------------------------- @@ -240,13 +256,15 @@ create the final profile. :end-before: [Ex. 8] +.. _ex 9: + 9. Selectors, Fillets, and Chamfers --------------------------------------------------- This example introduces multiple useful and important concepts. Firstly :meth:`~operations_generic.chamfer` and :meth:`~operations_generic.fillet` can be used to "bevel" and "round" edges respectively. Secondly, these two methods require an edge or a list of edges to operate on. To select all -edges, you could simply pass in ``*ex9.edges()`` (the star ``*`` operator unpacks the list). +edges, you could simply pass in ``ex9.edges()``. .. image:: assets/general_ex9.svg :align: center @@ -268,6 +286,8 @@ their z-position. In this case we want to use the ``[-1]`` group which, by conve be the highest z-dimension group. +.. _ex 10: + 10. Select Last and Hole --------------------------------------------------- @@ -297,6 +317,8 @@ be the highest z-dimension group. :end-before: [Ex. 10] +.. _ex 11: + 11. Use a face as a plane for BuildSketch and introduce GridLocations ---------------------------------------------------------------------------- @@ -339,6 +361,8 @@ Note that the direction implied by positive or negative inputs to amount is rela normal direction of the face or plane. As a result of this, unexpected behavior can occur if the extrude direction and mode/operation (ADD / ``+`` or SUBTRACT / ``-``) are not correctly set. +.. _ex 12: + 12. Defining an Edge with a Spline --------------------------------------------------- @@ -363,6 +387,8 @@ edge that needs a complex profile. The star ``*`` operator is again used to unpack the list. +.. _ex 13: + 13. CounterBoreHoles, CounterSinkHoles and PolarLocations ------------------------------------------------------------- @@ -390,6 +416,8 @@ Counter-sink and counter-bore holes are useful for creating recessed areas for f :class:`~build_common.PolarLocations` creates a list of points that are radially distributed. +.. _ex 14: + 14. Position on a line with '\@', '\%' and introduce Sweep ------------------------------------------------------------ @@ -407,7 +435,7 @@ consuming, and more difficult to maintain. * **Builder mode** - The :meth:`~operations_part.sweep` method takes any pending faces and sweeps them through the provided + The :meth:`~operations_generic.sweep` method takes any pending faces and sweeps them through the provided path (in this case the path is taken from the pending edges from ``ex14_ln``). :meth:`~operations_part.revolve` requires a single connected wire. The pending faces must lie on the path. @@ -418,7 +446,7 @@ consuming, and more difficult to maintain. * **Algebra mode** - The :meth:`~operations_part.sweep` method takes any faces and sweeps them through the provided + The :meth:`~operations_generic.sweep` method takes any faces and sweeps them through the provided path (in this case the path is taken from the pending edges from ``ex14_ln``). .. literalinclude:: general_examples_algebra.py @@ -428,6 +456,8 @@ consuming, and more difficult to maintain. It is also possible to use :class:`~geometry.Vector` addition (and other vector math operations) as seen in the ``l3`` variable. +.. _ex 15: + 15. Mirroring Symmetric Geometry --------------------------------------------------- @@ -453,6 +483,7 @@ Additionally the '@' operator is used to simplify the line segment commands. :start-after: [Ex. 15] :end-before: [Ex. 15] +.. _ex 16: 16. Mirroring 3D Objects --------------------------------------------------- @@ -476,6 +507,8 @@ The ``Plane.offset()`` method shifts the plane in the normal direction (positive :end-before: [Ex. 16] +.. _ex 17: + 17. Mirroring From Faces --------------------------------------------------- @@ -498,6 +531,8 @@ Here we select the farthest face in the Y-direction and turn it into a :class:`~ :end-before: [Ex. 17] +.. _ex 18: + 18. Creating Workplanes on Faces --------------------------------------------------- @@ -524,6 +559,8 @@ with a negative distance. :end-before: [Ex. 18] +.. _ex 19: + 19. Locating a workplane on a vertex --------------------------------------------------- @@ -556,6 +593,8 @@ this custom Axis. :end-before: [Ex. 19] +.. _ex 20: + 20. Offset Sketch Workplane --------------------------------------------------- @@ -578,6 +617,8 @@ negative x-direction. The resulting Plane is offset from the original position. :end-before: [Ex. 20] +.. _ex 21: + 21. Create a Workplanes in the center of another shape ------------------------------------------------------- @@ -600,6 +641,8 @@ positioning another cylinder perpendicular and halfway along the first. :end-before: [Ex. 21] +.. _ex 22: + 22. Rotated Workplanes --------------------------------------------------- @@ -629,6 +672,8 @@ example. extruded in the "both" (positive and negative) normal direction. +.. _ex 23: + 23. Revolve --------------------------------------------------- @@ -656,6 +701,8 @@ It is highly recommended to view your sketch before you attempt to call revolve. :end-before: [Ex. 23] +.. _ex 24: + 24. Loft --------------------------------------------------- @@ -680,6 +727,8 @@ Loft can behave unexpectedly when the input faces are not parallel to each other :end-before: [Ex. 24] +.. _ex 25: + 25. Offset Sketch --------------------------------------------------- @@ -706,6 +755,8 @@ They can be offset inwards or outwards, and with different techniques for extend corners (see :class:`~build_enums.Kind` in the Offset docs). +.. _ex 26: + 26. Offset Part To Create Thin features --------------------------------------------------- @@ -732,6 +783,8 @@ Note that self intersecting edges and/or faces can break both 2D and 3D offsets. :end-before: [Ex. 26] +.. _ex 27: + 27. Splitting an Object --------------------------------------------------- @@ -754,6 +807,8 @@ a face and offset half the width of the box. :end-before: [Ex. 27] +.. _ex 28: + 28. Locating features based on Faces --------------------------------------------------- @@ -780,6 +835,8 @@ a face and offset half the width of the box. We are able to create multiple workplanes by looping over the list of faces. +.. _ex 29: + 29. The Classic OCC Bottle --------------------------------------------------- @@ -803,6 +860,8 @@ the bottle opening. :end-before: [Ex. 29] +.. _ex 30: + 30. Bezier Curve --------------------------------------------------- @@ -826,6 +885,8 @@ create a closed line that is made into a face and extruded. :end-before: [Ex. 30] +.. _ex 31: + 31. Nesting Locations --------------------------------------------------- @@ -849,6 +910,8 @@ rotates any "children" groups by default. :end-before: [Ex. 31] +.. _ex 32: + 32. Python For-Loop --------------------------------------------------- @@ -875,6 +938,8 @@ separate calls to :meth:`~operations_part.extrude`. :end-before: [Ex. 32] +.. _ex 33: + 33. Python Function and For-Loop --------------------------------------------------- @@ -902,6 +967,8 @@ progressively modify the size of each square. :end-before: [Ex. 33] +.. _ex 34: + 34. Embossed and Debossed Text --------------------------------------------------- @@ -931,6 +998,8 @@ progressively modify the size of each square. :end-before: [Ex. 34] +.. _ex 35: + 35. Slots --------------------------------------------------- @@ -957,6 +1026,8 @@ progressively modify the size of each square. :end-before: [Ex. 35] +.. _ex 36: + 36. Extrude Until --------------------------------------------------- diff --git a/docs/joints.rst b/docs/joints.rst new file mode 100644 index 00000000..58095e1c --- /dev/null +++ b/docs/joints.rst @@ -0,0 +1,209 @@ +.. _joints: + +###### +Joints +###### + +:class:`~topology.Joint`'s enable Solid and Compound objects to be arranged +relative to each other in an intuitive manner - with the same degree of motion +that is found with the equivalent physical joints. :class:`~topology.Joint`'s always work +in pairs - a :class:`~topology.Joint` can only be connected to another :class:`~topology.Joint` as follows: + ++---------------------------------------+---------------------------------------------------------------------+--------------------+ +| :class:`~topology.Joint` | connect_to | Example | ++=======================================+=====================================================================+====================+ +| :class:`~joints.BallJoint` | :class:`~joints.RigidJoint` | Gimbal | ++---------------------------------------+---------------------------------------------------------------------+--------------------+ +| :class:`~joints.CylindricalJoint` | :class:`~joints.RigidJoint` | Screw | ++---------------------------------------+---------------------------------------------------------------------+--------------------+ +| :class:`~joints.LinearJoint` | :class:`~joints.RigidJoint`, :class:`~joints.RevoluteJoint` | Slider or Pin Slot | ++---------------------------------------+---------------------------------------------------------------------+--------------------+ +| :class:`~joints.RevoluteJoint` | :class:`~joints.RigidJoint` | Hinge | ++---------------------------------------+---------------------------------------------------------------------+--------------------+ +| :class:`~joints.RigidJoint` | :class:`~joints.RigidJoint` | Fixed | ++---------------------------------------+---------------------------------------------------------------------+--------------------+ + +Objects may have many joints bound to them each with an identifying label. All :class:`~topology.Joint` +objects have a ``symbol`` property that can be displayed to help visualize +their position and orientation (the `ocp-vscode `_ viewer +has built-in support for displaying joints). + +.. note:: + If joints are created within the scope of a :class:`~build_part.BuildPart` builder, the ``to_part`` + parameter need not be specified as the builder will, on exit, automatically transfer the joints created in its + scope to the part created. + +The following sections provide more detail on the available joints and describes how they are used. + +.. py:module:: joints + +*********** +Rigid Joint +*********** + +A rigid joint positions two components relative to each another with no freedom of movement. When a +:class:`~joints.RigidJoint` is instantiated it's assigned a ``label``, a part to bind to (``to_part``), +and a ``joint_location`` which defines both the position and orientation of the joint (see +:class:`~geometry.Location`) - as follows: + +.. code-block:: python + + RigidJoint(label="outlet", to_part=pipe, joint_location=path.location_at(1)) + +Once a joint is bound to a part this way, the :meth:`~topology.Joint.connect_to` method can be used to +repositioning another part relative to ``self`` which stay fixed - as follows: + +.. code-block:: python + + pipe.joints["outlet"].connect_to(flange_outlet.joints["pipe"]) + +.. note:: + Within a part all of the joint labels must be unique. + +The :meth:`~topology.Joint.connect_to` method only does a one time re-position of a part and does not +bind them in any way; however, putting them into an :ref:`assembly` will maintain there relative locations +as will combining parts with boolean operations or within a :class:`~build_part.BuildPart` context. + +As a example of creating parts with joints and connecting them together, consider the following code where +flanges are attached to the ends of a curved pipe: + +.. image:: assets/rigid_joints_pipe.png + +.. literalinclude:: rigid_joints_pipe.py + :emphasize-lines: 19-20, 23-24 + +Note how the locations of the joints are determined by the :meth:`~topology.Mixin1D.location_at` method +and how the ``-`` negate operator is used to reverse the direction of the location without changing its +poosition. Also note that the ``WeldNeckFlange`` class predefines two joints, one at the pipe end and +one at the face end - both of which are shown in the above image (generated by ocp-vscode with the +``render_joints=True`` flag set in the ``show`` function). + + +.. autoclass:: RigidJoint + +.. + :exclude-members: connect_to + + .. method:: connect_to(other: BallJoint, *, angles: RotationLike = None) + .. method:: connect_to(other: CylindricalJoint, *, position: float = None, angle: float = None) + :noindex: + .. method:: connect_to(other: LinearJoint, *, position: float = None) + :noindex: + .. method:: connect_to(other: RevoluteJoint, *, angle: float = None) + :noindex: + .. method:: connect_to(other: RigidJoint) + :noindex: + + Connect the ``RigidJoint`` to another Joint. +.. + + + +************** +Revolute Joint +************** + +Component rotates around axis like a hinge. The :ref:`joint_tutorial` covers Revolute Joints in detail. + +During instantiation of a :class:`~joints.RevoluteJoint` there are three parameters not present with +Rigid Joints: ``axis``, ``angle_reference``, and ``range`` that allow the circular motion to be fully +defined. + +When :meth:`~topology.Joint.connect_to` with a Revolute Joint, an extra ``angle`` parameter is present +which allows one to change the relative position of joined parts by changing a single value. + +.. autoclass:: RevoluteJoint + +.. + :exclude-members: connect_to + + .. method:: connect_to(other: RigidJoint, *, angle: float = None) + + Connect the ``RevoluteJoint`` to a ``RigidJoint`` + +************ +Linear Joint +************ + +Component moves along a single axis as with a sliding latch shown here: + +.. image:: assets/joint-latch-slide.png + +The code to generate these components follows: + +.. literalinclude:: slide_latch.py + :emphasize-lines: 30, 52, 55 + +.. image:: assets/joint-latch.png + :width: 65 % +.. image:: assets/joint-slide.png + :width: 27.5 % + +Note how the slide is constructed in a different orientation than the direction of motion. The +three highlighted lines of code show how the joints are created and connected together: + +* The :class:`~joints.LinearJoint` has an axis and limits of movement +* The :class:`~joints.RigidJoint` has a single location, orientated such that the knob will ultimately be "up" +* The ``connect_to`` specifies a position that must be within the predefined limits. + +The slider can be moved back and forth by just changing the ``position`` value. Values outside +of the limits will raise an exception. + +.. autoclass:: LinearJoint + +.. + :exclude-members: connect_to + + .. method:: connect_to(other: RevoluteJoint, *, position: float = None, angle: float = None) + .. method:: connect_to(other: RigidJoint, *, position: float = None) + :noindex: + + Connect the ``LinearJoint`` to either a ``RevoluteJoint`` or ``RigidJoint`` + +***************** +Cylindrical Joint +***************** + +A :class:`~joints.CylindricalJoint` allows a component to rotate around and moves along a single axis +like a screw combining the functionality of a :class:`~joints.LinearJoint` and a +:class:`~joints.RevoluteJoint` joint. The ``connect_to`` for these joints have both ``position`` and +``angle`` parameters as shown below extracted from the joint tutorial. + + +.. code-block::python + + hinge_outer.joints["hole2"].connect_to(m6_joint, position=5 * MM, angle=30) + +.. autoclass:: CylindricalJoint + +.. + :exclude-members: connect_to + + .. method:: connect_to(other: RigidJoint, *, position: float = None, angle: float = None) + + Connect the ``CylindricalJoint`` to a ``RigidJoint`` + +********** +Ball Joint +********** + +A component rotates around all 3 axes using a gimbal system (3 nested rotations). A :class:`~joints.BallJoint` +is found within a rod end as shown here: + +.. image:: assets/rod_end.png + +.. literalinclude:: rod_end.py + :emphasize-lines: 37-41,48,50 + +Note how limits are defined during the instantiation of the ball joint when ensures that the pin or bolt +within the rod end does not interfer with the rod end itself. The ``connect_to`` sets the three angles +(only two are significant in this example). + +.. autoclass:: BallJoint + +.. + :exclude-members: connect_to + + .. method:: connect_to(other: RigidJoint, *, angles: RotationLike = None) + + Connect a ``BallJoint`` to a ``RigidJoint`` diff --git a/docs/key_concepts.rst b/docs/key_concepts.rst index dfb47604..4faf1c0e 100644 --- a/docs/key_concepts.rst +++ b/docs/key_concepts.rst @@ -1,3 +1,15 @@ +########################### +Key Concepts (builder mode) +########################### + +There are two primary APIs provided by build123d: builder and algebra. The builder +api may be easier for new users as it provides some assistance and shortcuts; however, +if you know what a Quaternion is you might prefer the algebra API which allows +CAD objects to be created in the style of mathematical equations. Both API can +be mixed in the same model with the exception that the algebra API can't be used +from within a builder context. As with music, there is no "best" genre or API, +use the one you prefer or both if you like. + The following key concepts will help new users understand build123d quickly. Builders @@ -59,39 +71,36 @@ information - as follows: In this example, ``Box`` is in the scope of ``part_builder`` while ``Circle`` is in the scope of ``sketch_builder``. -Workplane Contexts -================== +Workplanes +========== As build123d is a 3D CAD package one must be able to position objects anywhere. As one -frequently will work in the same plane for a sequence of operations, a workplane is used -to aid in the location of features. The default workplane in most cases is the XY plane +frequently will work in the same plane for a sequence of operations, the first parameter(s) +of the builders is a (sequence of) workplane(s) which is (are) used +to aid in the location of features. The default workplane in most cases is the ``Plane.XY`` where a tuple of numbers represent positions on the x and y axes. However workplanes can be generated on any plane which allows users to put a workplane where they are working and then work in local 2D coordinate space. -To facilitate this a ``Workplanes`` stateful context is used to create a scope where a given -workplane will apply. For example: .. code-block:: python with BuildPart(Plane.XY) as example: - with BuildSketch() as bottom: + with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[0]) as bottom: + ... + with BuildSketch(Plane.XZ) as vertical: + ... + with BuildSketch(example.faces().sort_by(sort_by=Axis.Z)[-1]) as top: ... - with Workplanes(Plane.XZ) as vertical: - with BuildSketch() as side: - ... - with Workplanes(example.faces().sort_by(SortBy.Z)[-1]): - with BuildSketch() as top: - ... When ``BuildPart`` is invoked it creates the workplane provided as a parameter (which has a -default of the XY plane). The ``bottom`` sketch is therefore created on the XY plane. Subsequently -the user has created the ``vertical`` plane (XZ) on which the ``side`` sketch is created. All -objects or operations within the scope of a workplane will automatically be orientated with +default of the ``Plane.XY``). The ``bottom`` sketch is therefore created on the ``Plane.XY`` but with the +normal reversed to point down. Subsequently the user has created the ``vertical`` (``Plane.XZ```) sketch. +All objects or operations within the scope of a workplane will automatically be orientated with respect to this plane so the user only has to work with local coordinates. -Workplanes can be created from faces as well. The ``top`` sketch is positioned on top -of ``example`` by selecting its faces and finding the one with the greatest z value. +As shown above, workplanes can be created from faces as well. The ``top`` sketch is +positioned on top of ``example`` by selecting its faces and finding the one with the greatest z value. One is not limited to a single workplane at a time. In the following example all six faces of the first box is used to define workplanes which are then used to position @@ -103,16 +112,73 @@ rotated boxes. with bd.BuildPart() as bp: bd.Box(3, 3, 3) - with bd.Workplanes(*bp.faces()): - bd.Box(1, 2, 0.1, rotation=(0, 0, 45)) + with bd.BuildSketch(*bp.faces()): + bd.Rectangle(1, 2, rotation=45) + bd.extrude(amount=0.1) This is the result: .. image:: boxes_on_faces.svg :align: center -Location Context -================ +.. _location_context_link: + +Location +======== + +A :class:`~geometry.Location` represents a combination of translation and rotation +applied to a topological or geometric object. It encapsulates information +about the spatial orientation and position of a shape within its reference +coordinate system. This allows for efficient manipulation of shapes within +complex assemblies or transformations. The location is typically used to +position shapes accurately within a 3D scene, enabling operations like +assembly, and boolean operations. It's an essential component in build123d +for managing the spatial relationships of geometric entities, providing a +foundation for precise 3D modeling and engineering applications. + +The topological classes (sub-classes of :class:`~topology.Shape`) and the geometric classes +:class:`~geometry.Axis` and :class:`~geometry.Plane` all have a ``location`` property. +The :class:`~geometry.Location` class itself has ``position`` and ``orientation`` properties +that have setters and getters as shown below: + + +.. doctest:: + + >>> from build123d import * + >>> # Create an object and extract its location + >>> b = Box(1, 1, 1) + >>> box_location = b.location + >>> box_location + (p=(0.00, 0.00, 0.00), o=(-0.00, 0.00, -0.00)) + >>> # Set position and orientation independently + >>> box_location.position = (1, 2, 3) + >>> box_location.orientation = (30, 40, 50) + >>> box_location.position + Vector: (1.0, 2.0, 3.0) + >>> box_location.orientation + Vector: (29.999999999999993, 40.00000000000002, 50.000000000000036) + +Combining the getter and setter enables relative changes as follows: + +.. doctest:: + + >>> # Relative change + >>> box_location.position += (3, 2, 1) + >>> box_location.position + Vector: (4.0, 4.0, 4.0) + +There are also four methods that are used to change the location of objects: + +* :meth:`~topology.Shape.locate` - absolute change of this object +* :meth:`~topology.Shape.located` - absolute change of copy of this object +* :meth:`~topology.Shape.move` - relative change of this object +* :meth:`~topology.Shape.moved` - relative change of copy of this object + +Locations can be combined with the ``*`` operator and have their direction flipped with +the ``-`` operator. + +Locations Context +================= When positioning objects or operations within a builder Location Contexts are used. These function in a very similar was to the builders in that they create a context where one or @@ -136,12 +202,12 @@ Note that these contexts are creating Location objects not just simple points. T isn't obvious until the ``PolarLocations`` context is used which can also rotate objects within its scope - much as the hour and minute indicator on an analogue clock. -Also note that the locations are local to the current workplane(s). However, it's easy for a user -to retrieve the global locations relative to the current workplane(s) as follows: +Also note that the locations are local to the current location(s) - i.e. ``Locations`` can be +nested. It's easy for a user to retrieve the global locations: .. code-block:: python - with Workplanes(Plane.XY, Plane.XZ): + with Locations(Plane.XY, Plane.XZ): locs = GridLocations(1, 1, 2, 2) for l in locs: print(l) diff --git a/docs/key_concepts_algebra.rst b/docs/key_concepts_algebra.rst index 6eee8a23..ae67ba39 100644 --- a/docs/key_concepts_algebra.rst +++ b/docs/key_concepts_algebra.rst @@ -1,3 +1,6 @@ +########################### +Key Concepts (algebra mode) +########################### Build123d's algebra mode works on objects of the classes ``Shape``, ``Part``, ``Sketch`` and ``Curve`` and is based on two concepts: @@ -42,7 +45,7 @@ Object arithmetic Placement arithmetic ======================= -A ``Part``, ``Sketch``or ``Curve`` does not have any location or rotation paramater. +A ``Part``, ``Sketch`` or ``Curve`` does not have any location or rotation paramater. The rationale is that an object defines its topology (shape, sizes and its center), but does not know where in space it will be located. Instead, it will be relocated with the ``*`` operator onto a plane and to location relative to the plane (similar ``moved``). diff --git a/docs/line_types.py b/docs/line_types.py new file mode 100644 index 00000000..2c919025 --- /dev/null +++ b/docs/line_types.py @@ -0,0 +1,48 @@ +""" +Create line type examples + +name: line_types.py +by: Gumyr +date: July 25th 2023 + +desc: + This python module generates sample of all the available line types. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from build123d import * + +exporter = ExportSVG(scale=1) +exporter.add_layer(name="Text", fill_color=(0, 0, 0)) +line_types = [l for l in LineType.__members__] +text_locs = Pos((100, 0, 0)) * GridLocations(0, 6, 1, len(line_types)).locations +line_locs = Pos((105, 0, 0)) * GridLocations(0, 6, 1, len(line_types)).locations +for line_type, text_loc, line_loc in zip(line_types, text_locs, line_locs): + exporter.add_layer(name=line_type, line_type=getattr(LineType, line_type)) + exporter.add_shape( + Compound.make_text( + "LineType." + line_type, + font_size=5, + align=(Align.MAX, Align.CENTER), + ).locate(text_loc), + layer="Text", + ) + exporter.add_shape( + Edge.make_line((0, 0), (100, 0)).locate(line_loc), layer=line_type + ) +exporter.write("assets/line_types.svg") diff --git a/docs/make.bat b/docs/make.bat old mode 100644 new mode 100755 diff --git a/docs/objects.rst b/docs/objects.rst new file mode 100644 index 00000000..11751967 --- /dev/null +++ b/docs/objects.rst @@ -0,0 +1,467 @@ +####### +Objects +####### + +Objects are Python classes that take parameters as inputs and create 1D, 2D or 3D Shapes. +For example, a :class:`~objects_part.Torus` is defined by a major and minor radii. In +Builder mode, objects are positioned with ``Locations`` while in Algebra mode, objects +are positioned with the ``*`` operator and shown in these examples: + +.. code-block:: python + + with BuildPart() as disk: + with BuildSketch(): + Circle(a) + with Locations((b, 0.0)): + Rectangle(c, c, mode=Mode.SUBTRACT) + with Locations((0, b)): + Circle(d, mode=Mode.SUBTRACT) + extrude(amount=c) + +.. code-block:: python + + sketch = Circle(a) - Pos(b, 0.0) * Rectangle(c, c) - Pos(0.0, b) * Circle(d) + disk = extrude(sketch, c) + +The following sections describe the 1D, 2D and 3D objects: + +Align +----- + +2D/Sketch and 3D/Part objects can be aligned relative to themselves, either centered, or justified +right or left of each Axis. The following diagram shows how this alignment works in 2D: + +.. image:: assets/align.svg + :align: center + +For example: + +.. code-block:: python + + with BuildSketch(): + Circle(1, align=(Align.MIN, Align.MIN)) + +creates a circle who's minimal X and Y values are on the X and Y axis and is located in the top right corner. +The ``Align`` enum has values: ``MIN``, ``CENTER`` and ``MAX``. + +In 3D the ``align`` parameter also contains a Z align value but otherwise works in the same way. + +Note that the ``align`` will also accept a single ``Align`` value which will be used on all axes - +as shown here: + +.. code-block:: python + + with BuildSketch(): + Circle(1, align=Align.MIN) + +Mode +---- + +With the Builder API the ``mode`` parameter controls how objects are combined with lines, sketches, or parts +under construction. The ``Mode`` enum has values: + +* ``ADD``: fuse this object to the object under construction +* ``SUBTRACT``: cut this object from the object under construction +* ``INTERSECT``: intersect this object with the object under construction +* ``REPLACE``: replace the object under construction with this object +* ``PRIVATE``: don't interact with the object under construction at all + +The Algebra API doesn't use the ``mode`` parameter - users combine objects with operators. + +1D Objects +---------- + +The following objects all can be used in BuildLine contexts. Note that +1D objects are not affected by ``Locations`` in Builder mode. + +.. grid:: 3 + + .. grid-item-card:: :class:`~objects_curve.Bezier` + + .. image:: assets/bezier_curve_example.svg + + +++ + Curve defined by control points and weights + + .. grid-item-card:: :class:`~objects_curve.CenterArc` + + .. image:: assets/center_arc_example.svg + + +++ + Arc defined by center, radius, & angles + + .. grid-item-card:: :class:`~objects_curve.EllipticalCenterArc` + + .. image:: assets/elliptical_center_arc_example.svg + + +++ + Elliptical arc defined by center, radii & angles + + .. grid-item-card:: :class:`~objects_curve.FilletPolyline` + + .. image:: assets/filletpolyline_example.svg + + +++ + Polyline with filleted corners defined by pts and radius + + .. grid-item-card:: :class:`~objects_curve.Helix` + + .. image:: assets/helix_example.svg + + +++ + Helix defined pitch, radius and height + + .. grid-item-card:: :class:`~objects_curve.JernArc` + + .. image:: assets/jern_arc_example.svg + + +++ + Arc define by start point, tangent, radius and angle + + .. grid-item-card:: :class:`~objects_curve.Line` + + .. image:: assets/line_example.svg + + +++ + Line defined by end points + + .. grid-item-card:: :class:`~objects_curve.PolarLine` + + .. image:: assets/polar_line_example.svg + + +++ + Line defined by start, angle and length + + .. grid-item-card:: :class:`~objects_curve.Polyline` + + .. image:: assets/polyline_example.svg + + +++ + Multiple line segments defined by points + + .. grid-item-card:: :class:`~objects_curve.RadiusArc` + + .. image:: assets/radius_arc_example.svg + + +++ + Arc define by two points and a radius + + .. grid-item-card:: :class:`~objects_curve.SagittaArc` + + .. image:: assets/sagitta_arc_example.svg + + +++ + Arc define by two points and a sagitta + + .. grid-item-card:: :class:`~objects_curve.Spline` + + .. image:: assets/spline_example.svg + + +++ + Curve define by points + + .. grid-item-card:: :class:`~objects_curve.TangentArc` + + .. image:: assets/tangent_arc_example.svg + + +++ + Curve define by two points and a tangent + + .. grid-item-card:: :class:`~objects_curve.ThreePointArc` + + .. image:: assets/three_point_arc_example.svg + + +++ + Curve define by three points + + +Reference +^^^^^^^^^ +.. py:module:: objects_curve + +.. autoclass:: BaseLineObject +.. autoclass:: Bezier +.. autoclass:: CenterArc +.. autoclass:: EllipticalCenterArc +.. autoclass:: FilletPolyline +.. autoclass:: Helix +.. autoclass:: JernArc +.. autoclass:: Line +.. autoclass:: PolarLine +.. autoclass:: Polyline +.. autoclass:: RadiusArc +.. autoclass:: SagittaArc +.. autoclass:: Spline +.. autoclass:: TangentArc +.. autoclass:: ThreePointArc + +2D Objects +---------- + +.. grid:: 3 + + .. grid-item-card:: :class:`~objects_sketch.Arrow` + + .. image:: assets/arrow.svg + + +++ + Arrow with head and path for shaft + + .. grid-item-card:: :class:`~objects_sketch.ArrowHead` + + .. image:: assets/arrow_head.svg + + +++ + Arrow head with multiple types + + + .. grid-item-card:: :class:`~objects_sketch.Circle` + + .. image:: assets/circle_example.svg + + +++ + Circle defined by radius + + .. grid-item-card:: :class:`~objects_sketch.DimensionLine` + + .. image:: assets/d_line.svg + + +++ + Dimension line + + + .. grid-item-card:: :class:`~objects_sketch.Ellipse` + + .. image:: assets/ellipse_example.svg + + +++ + Ellipse defined by major and minor radius + + .. grid-item-card:: :class:`~objects_sketch.ExtensionLine` + + .. image:: assets/e_line.svg + + +++ + Extension lines for distance or angles + + .. grid-item-card:: :class:`~objects_sketch.Polygon` + + .. image:: assets/polygon_example.svg + + +++ + Polygon defined by points + + .. grid-item-card:: :class:`~objects_sketch.Rectangle` + + .. image:: assets/rectangle_example.svg + + +++ + Rectangle defined by width and height + + .. grid-item-card:: :class:`~objects_sketch.RectangleRounded` + + .. image:: assets/rectangle_rounded_example.svg + + +++ + Rectangle with rounded corners defined by width, height, and radius + + .. grid-item-card:: :class:`~objects_sketch.RegularPolygon` + + .. image:: assets/regular_polygon_example.svg + + +++ + RegularPolygon defined by radius and number of sides + + .. grid-item-card:: :class:`~objects_sketch.SlotArc` + + .. image:: assets/slot_arc_example.svg + + +++ + SlotArc defined by arc and height + + .. grid-item-card:: :class:`~objects_sketch.SlotCenterPoint` + + .. image:: assets/slot_center_point_example.svg + + +++ + SlotCenterPoint defined by two points and a height + + .. grid-item-card:: :class:`~objects_sketch.SlotCenterToCenter` + + .. image:: assets/slot_center_to_center_example.svg + + +++ + SlotCenterToCenter defined by center separation and height + + .. grid-item-card:: :class:`~objects_sketch.SlotOverall` + + .. image:: assets/slot_overall_example.svg + + +++ + SlotOverall defined by end-to-end length and height + + .. grid-item-card:: :class:`~objects_sketch.TechnicalDrawing` + + .. image:: assets/tech_drawing.svg + + +++ + A technical drawing with descriptions + + .. grid-item-card:: :class:`~objects_sketch.Text` + + .. image:: assets/text_example.svg + + +++ + Text defined by string and font parameters + + .. grid-item-card:: :class:`~objects_sketch.Trapezoid` + + .. image:: assets/trapezoid_example.svg + + +++ + Trapezoid defined by width, height and interior angles + + + +Reference +^^^^^^^^^ +.. py:module:: objects_sketch + +.. autoclass:: BaseSketchObject +.. autoclass:: drafting.Arrow +.. autoclass:: drafting.ArrowHead +.. autoclass:: Circle +.. autoclass:: drafting.DimensionLine +.. autoclass:: Ellipse +.. autoclass:: drafting.ExtensionLine +.. autoclass:: Polygon +.. autoclass:: Rectangle +.. autoclass:: RectangleRounded +.. autoclass:: RegularPolygon +.. autoclass:: SlotArc +.. autoclass:: SlotCenterPoint +.. autoclass:: SlotCenterToCenter +.. autoclass:: SlotOverall +.. autoclass:: drafting.TechnicalDrawing +.. autoclass:: Text +.. autoclass:: Trapezoid + +3D Objects +---------- + +.. grid:: 3 + + .. grid-item-card:: :class:`~objects_part.Box` + + .. image:: assets/box_example.svg + + +++ + Box defined by length, width, height + + .. grid-item-card:: :class:`~objects_part.Cone` + + .. image:: assets/cone_example.svg + + +++ + Cone defined by radii and height + + .. grid-item-card:: :class:`~objects_part.CounterBoreHole` + + .. image:: assets/counter_bore_hole_example.svg + + +++ + Counter bore hole defined by radii and depths + + .. grid-item-card:: :class:`~objects_part.CounterSinkHole` + + .. image:: assets/counter_sink_hole_example.svg + + +++ + Counter sink hole defined by radii and depth and angle + + .. grid-item-card:: :class:`~objects_part.Cylinder` + + .. image:: assets/cylinder_example.svg + + +++ + Cylinder defined by radius and height + + .. grid-item-card:: :class:`~objects_part.Hole` + + .. image:: assets/hole_example.svg + + +++ + Hole defined by radius and depth + + .. grid-item-card:: :class:`~objects_part.Sphere` + + .. image:: assets/sphere_example.svg + + +++ + Sphere defined by radius and arc angles + + .. grid-item-card:: :class:`~objects_part.Torus` + + .. image:: assets/torus_example.svg + + +++ + Torus defined major and minor radii + + .. grid-item-card:: :class:`~objects_part.Wedge` + + .. image:: assets/wedge_example.svg + + +++ + Wedge defined by lengths along multiple Axes + + +Reference +^^^^^^^^^ +.. py:module:: objects_part + +.. autoclass:: BasePartObject +.. autoclass:: Box +.. autoclass:: Cone +.. autoclass:: CounterBoreHole +.. autoclass:: CounterSinkHole +.. autoclass:: Cylinder +.. autoclass:: Hole +.. autoclass:: Sphere +.. autoclass:: Torus +.. autoclass:: Wedge + +Custom Objects +-------------- + +All of the objects presented above were created using one of three base object classes: +:class:`~objects_curve.BaseLineObject` , :class:`~objects_sketch.BaseSketchObject` , and +:class:`~objects_part.BasePartObject` . Users can use these base object classes to +easily create custom objects that have all the functionality of the core objects. + +.. image:: assets/card_box.svg + :align: center + +Here is an example of a custom sketch object specially created as part of the design of +this playing card storage box (:download:`see the playing_cards.py example <../examples/playing_cards.py>`): + +.. literalinclude:: ../examples/playing_cards.py + :start-after: [Club] + :end-before: [Club] + +Here the new custom object class is called ``Club`` and it's a sub-class of +:class:`~objects_sketch.BaseSketchObject` . The ``__init__`` method contains all +of the parameters used to instantiate the custom object, specially a ``height``, +``rotation``, ``align``, and ``mode`` - your objects may contain a sub or super set of +these parameters but should always contain a ``mode`` parameter such that it +can be combined with a builder's object. + +Next is the creation of the object itself, in this case a sketch of the club suit. + +The final line calls the ``__init__`` method of the super class - i.e. +:class:`~objects_sketch.BaseSketchObject` with its parameters. + +That's it, now the ``Club`` object can be used anywhere a :class:`~objects_sketch.Circle` +would be used - with either the Algebra or Builder API. + +.. image:: assets/buildline_example_6.svg + :align: center diff --git a/docs/objects_1d.py b/docs/objects_1d.py new file mode 100644 index 00000000..939cbdec --- /dev/null +++ b/docs/objects_1d.py @@ -0,0 +1,246 @@ +# [Setup] +from build123d import * +from ocp_vscode import * + +dot = Circle(0.05) + +# [Ex. 1] +with BuildLine() as example_1: + Line((0, 0), (2, 0)) + ThreePointArc((0, 0), (1, 1), (2, 0)) +# [Ex. 1] +s = 100 / max(*example_1.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_1.line) +svg.write("assets/buildline_example_1.svg") +# [Ex. 2] +with BuildLine() as example_2: + l1 = Line((0, 0), (2, 0)) + l2 = ThreePointArc(l1 @ 0, (1, 1), l1 @ 1) +# [Ex. 2] + +# [Ex. 3] +with BuildLine() as example_3: + l1 = Line((0, 0), (2, 0)) + l2 = ThreePointArc(l1 @ 0, l1 @ 0.5 + (0, 1), l1 @ 1) +# [Ex. 3] + +# [Ex. 4] +with BuildLine() as example_4: + l1 = Line((0, 0), (2, 0)) + l2 = ThreePointArc(l1 @ 0, l1 @ 0.5 + (0, l1.length / 2), l1 @ 1) +# [Ex. 4] + +# [Ex. 5] +with BuildLine() as example_5: + l1 = Line((0, 0), (5, 0)) + l2 = Line(l1 @ 1, l1 @ 1 + (0, l1.length - 1)) + l3 = JernArc(start=l2 @ 1, tangent=l2 % 1, radius=0.5, arc_size=90) + l4 = Line(l3 @ 1, (0, l2.length + l3.radius)) +# [Ex. 5] +s = 100 / max(*example_5.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(example_5.line) +svg.add_shape(dot.moved(Location(l1 @ 1))) +svg.add_shape(dot.moved(Location(l2 @ 1))) +svg.add_shape(dot.moved(Location(l3 @ 1))) +svg.add_shape(PolarLine(l2 @ 1, 0.5, direction=l2 % 1), "dashed") +svg.write("assets/buildline_example_5.svg") +# [Ex. 6] +with BuildSketch() as example_6: + with BuildLine() as club_outline: + l0 = Line((0, -188), (76, -188)) + b0 = Bezier(l0 @ 1, (61, -185), (33, -173), (17, -81)) + b1 = Bezier(b0 @ 1, (49, -128), (146, -145), (167, -67)) + b2 = Bezier(b1 @ 1, (187, 9), (94, 52), (32, 18)) + b3 = Bezier(b2 @ 1, (92, 57), (113, 188), (0, 188)) + mirror(about=Plane.YZ) + make_face() + # [Ex. 6] +s = 100 / max(*example_6.sketch.bounding_box().size) +svg = ExportSVG(scale=s, margin=5) +svg.add_shape(example_6.sketch) +svg.write("assets/buildline_example_6.svg") + +# [Ex. 7] +with BuildPart() as example_7: + with BuildLine() as example_7_path: + l1 = RadiusArc((0, 0), (1, 1), 2) + l2 = Spline(l1 @ 1, (2, 3), (3, 3), tangents=(l1 % 1, (0, -1))) + l3 = Line(l2 @ 1, (3, 0)) + with BuildSketch(Plane(origin=l1 @ 0, z_dir=l1 % 0)) as example_7_section: + Circle(0.1) + sweep() +# [Ex. 7] +visible, hidden = example_7.part.project_to_viewport((100, -50, 100)) +s = 100 / max(*Compound(children=visible + hidden).bounding_box().size) +exporter = ExportSVG(scale=s) +exporter.add_layer("Visible") +exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) +exporter.add_shape(visible, layer="Visible") +exporter.add_shape(hidden, layer="Hidden") +exporter.write("assets/buildline_example_7.svg") +# [Ex. 8] +with BuildLine(Plane.YZ) as example_8: + l1 = Line((0, 0), (5, 0)) + l2 = Line(l1 @ 1, l1 @ 1 + (0, l1.length - 1)) + l3 = JernArc(start=l2 @ 1, tangent=l2 % 1, radius=0.5, arc_size=90) + l4 = Line(l3 @ 1, (0, l2.length + l3.radius)) +# [Ex. 8] +scene = Compound.make_compound(example_8.line) + Compound.make_triad(2) +visible, _hidden = scene.project_to_viewport((100, -50, 100)) +s = 100 / max(*Compound(children=visible + hidden).bounding_box().size) +exporter = ExportSVG(scale=s) +exporter.add_layer("Visible") +exporter.add_shape(visible, layer="Visible") +exporter.write("assets/buildline_example_8.svg") + + +pts = [(0, 0), (2 / 3, 2 / 3), (0, 4 / 3), (-4 / 3, 0), (0, -2), (4, 0), (0, 3)] +wts = [1.0, 1.0, 2.0, 3.0, 4.0, 2.0, 1.0] +with BuildLine() as bezier_curve: + Bezier(*pts, weights=wts) + +s = 100 / max(*bezier_curve.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(bezier_curve.line) +for pt in pts: + svg.add_shape(dot.moved(Location(Vector(pt)))) +svg.write("assets/bezier_curve_example.svg") + +with BuildLine() as center_arc: + CenterArc((0, 0), 3, 0, 90) +s = 100 / max(*center_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(center_arc.line) +svg.add_shape(dot.moved(Location(Vector((0, 0))))) +svg.write("assets/center_arc_example.svg") + +with BuildLine() as elliptical_center_arc: + EllipticalCenterArc((0, 0), 2, 3, 0, 90) +s = 100 / max(*elliptical_center_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(elliptical_center_arc.line) +svg.add_shape(dot.moved(Location(Vector((0, 0))))) +svg.write("assets/elliptical_center_arc_example.svg") + +with BuildLine() as helix: + Helix(1, 3, 1) +scene = Compound.make_compound(helix.line) + Compound.make_triad(0.5) +visible, _hidden = scene.project_to_viewport((1, 1, 1)) +s = 100 / max(*Compound(children=visible).bounding_box().size) +exporter = ExportSVG(scale=s) +exporter.add_layer("Visible") +exporter.add_shape(visible, layer="Visible") +exporter.write("assets/helix_example.svg") + +with BuildLine() as jern_arc: + JernArc((1, 1), (1, 0.5), 2, 100) +s = 100 / max(*jern_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(jern_arc.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(PolarLine((1, 1), 0.5, direction=(1, 0.5)), "dashed") +svg.write("assets/jern_arc_example.svg") + +with BuildLine() as line: + Line((1, 1), (3, 3)) +s = 100 / max(*line.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(line.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((3, 3))))) +svg.write("assets/line_example.svg") + +with BuildLine() as polar_line: + PolarLine((1, 1), 2.5, 60) +s = 100 / max(*polar_line.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(polar_line.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(PolarLine((1, 1), 4, angle=60), "dashed") +svg.write("assets/polar_line_example.svg") + +with BuildLine() as polyline: + Polyline((1, 1), (1.5, 2.5), (3, 3)) +s = 100 / max(*polyline.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(polyline.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((1.5, 2.5))))) +svg.add_shape(dot.moved(Location(Vector((3, 3))))) +svg.write("assets/polyline_example.svg") + +with BuildLine(Plane.YZ) as filletpolyline: + FilletPolyline((0, 0, 0), (0, 10, 2), (0, 10, 10), (5, 20, 10), radius=2) +show(filletpolyline) +scene = Compound.make_compound(filletpolyline.line) + Compound.make_triad(2) +visible, _hidden = scene.project_to_viewport((0, 0, 1), (0, 1, 0)) +s = 100 / max(*Compound(children=visible).bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(visible) +svg.write("assets/filletpolyline_example.svg") + +with BuildLine() as radius_arc: + RadiusArc((1, 1), (3, 3), 2) +s = 100 / max(*radius_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(radius_arc.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((3, 3))))) +svg.write("assets/radius_arc_example.svg") + +with BuildLine() as sagitta_arc: + SagittaArc((1, 1), (3, 1), 1) +s = 100 / max(*sagitta_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(sagitta_arc.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((3, 1))))) +svg.write("assets/sagitta_arc_example.svg") + +with BuildLine() as spline: + Spline((1, 1), (2, 1.5), (1, 2), (2, 2.5), (1, 3)) +s = 100 / max(*spline.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(spline.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((2, 1.5))))) +svg.add_shape(dot.moved(Location(Vector((1, 2))))) +svg.add_shape(dot.moved(Location(Vector((2, 2.5))))) +svg.add_shape(dot.moved(Location(Vector((1, 3))))) +svg.write("assets/spline_example.svg") + +with BuildLine() as tangent_arc: + TangentArc((1, 1), (3, 3), tangent=(1, 0)) +s = 100 / max(*tangent_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(tangent_arc.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((3, 3))))) +svg.add_shape(PolarLine((1, 1), 1, direction=(1, 0)), "dashed") +svg.write("assets/tangent_arc_example.svg") + +with BuildLine() as three_point_arc: + ThreePointArc((1, 1), (1.5, 2), (3, 3)) +s = 100 / max(*three_point_arc.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(three_point_arc.line) +svg.add_shape(dot.moved(Location(Vector((1, 1))))) +svg.add_shape(dot.moved(Location(Vector((1.5, 2))))) +svg.add_shape(dot.moved(Location(Vector((3, 3))))) +svg.write("assets/three_point_arc_example.svg") +# +# show_object(example_1.line, name="Ex. 1") +# show_object(example_2.line, name="Ex. 2") +# show_object(example_3.line, name="Ex. 3") +# show_object(example_4.line, name="Ex. 4") +# show_object(example_5.line, name="Ex. 5") +# show_object(example_6.line, name="Ex. 6") +# show_object(example_7_path.line, name="Ex. 7 path") +# show_object(example_8.line, name="Ex. 8") diff --git a/docs/objects_2d.py b/docs/objects_2d.py new file mode 100644 index 00000000..ac1a8b60 --- /dev/null +++ b/docs/objects_2d.py @@ -0,0 +1,306 @@ +# [Setup] +from build123d import * + +dot = Circle(0.05) + +# [Setup] +svg_opts1 = {"pixel_scale": 100, "show_axes": False, "show_hidden": False} +svg_opts2 = {"pixel_scale": 300, "show_axes": True, "show_hidden": False} +svg_opts3 = {"pixel_scale": 2, "show_axes": False, "show_hidden": False} +svg_opts4 = {"pixel_scale": 5, "show_axes": False, "show_hidden": False} + +# [Ex. 1] +with BuildSketch() as example_1: + Circle(1) +# [Ex. 1] +s = 100 / max(*example_1.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_1.sketch) +svg.write("assets/circle_example.svg") + +# [Ex. 2] +with BuildSketch() as example_2: + Ellipse(1.5, 1) +# [Ex. 2] +s = 100 / max(*example_2.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_2.sketch) +svg.write("assets/ellipse_example.svg") + +# [Ex. 3] +with BuildSketch() as example_3: + inner = PolarLocations(0.5, 5, 0).local_locations + outer = PolarLocations(1.5, 5, 36).local_locations + points = [p.position for pair in zip(inner, outer) for p in pair] + Polygon(*points) +# [Ex. 3] +s = 100 / max(*example_3.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_3.sketch) +svg.write("assets/polygon_example.svg") + +# [Ex. 4] +with BuildSketch() as example_4: + Rectangle(2, 1) +# [Ex. 4] +s = 100 / max(*example_4.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_4.sketch) +svg.write("assets/rectangle_example.svg") + +# [Ex. 5] +with BuildSketch() as example_5: + RectangleRounded(2, 1, 0.25) +# [Ex. 5] +s = 100 / max(*example_5.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_5.sketch) +svg.write("assets/rectangle_rounded_example.svg") + +# [Ex. 6] +with BuildSketch() as example_6: + RegularPolygon(1, 6) +# [Ex. 6] +s = 100 / max(*example_6.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_6.sketch) +svg.write("assets/regular_polygon_example.svg") + +# [Ex. 7] +with BuildSketch() as example_7: + arc = Edge.make_circle(1, start_angle=0, end_angle=45) + SlotArc(arc, 0.25) +# [Ex. 7] +s = 100 / max(*example_7.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(example_7.sketch) +svg.add_shape(arc, "dashed") +svg.write("assets/slot_arc_example.svg") + +# [Ex. 8] +with BuildSketch() as example_8: + c = (0, 0) + p = (0, 1) + SlotCenterPoint(c, p, 0.25) +# [Ex. 8] +s = 100 / max(*example_8.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape(example_8.sketch) +svg.add_shape(dot.moved(Location(c)), "dashed") +svg.add_shape(dot.moved(Location(p)), "dashed") +svg.write("assets/slot_center_point_example.svg") + +# [Ex. 9] +with BuildSketch() as example_9: + SlotCenterToCenter(1, 0.25, rotation=90) +# [Ex. 9] +s = 100 / max(*example_9.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_9.sketch) +svg.write("assets/slot_center_to_center_example.svg") + +# [Ex. 10] +with BuildSketch() as example_10: + SlotOverall(1, 0.25) +# [Ex. 10] +s = 100 / max(*example_10.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_10.sketch) +svg.write("assets/slot_overall_example.svg") + +# [Ex. 11] +with BuildSketch() as example_11: + Text("text", 1) +# [Ex. 11] +s = 100 / max(*example_11.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(example_11.sketch) +svg.write("assets/text_example.svg") + +# [Ex. 12] +with BuildSketch() as example_12: + t = Trapezoid(2, 1, 80) + with Locations((-0.6, -0.3)): + Text("80°", 0.3, mode=Mode.SUBTRACT) +# [Ex. 12] +s = 100 / max(*example_12.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.DASHED) +svg.add_shape( + Edge.make_circle( + 0.75, + Plane(t.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[0].to_tuple()), + start_angle=0, + end_angle=80, + ), + "dashed", +) +svg.add_shape(example_12.sketch) +svg.write("assets/trapezoid_example.svg") + +# [Ex. 13] +length, radius = 40.0, 60.0 + +with BuildSketch() as circle_with_hole: + Circle(radius=radius) + Rectangle(width=length, height=length, mode=Mode.SUBTRACT) +# [Ex. 13] +s = 100 / max(*circle_with_hole.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(circle_with_hole.sketch) +svg.write("assets/circle_with_hole.svg") + +# [Ex. 14] +with BuildPart() as controller: + # Create the side view of the controller + with BuildSketch(Plane.YZ) as profile: + with BuildLine(): + Polyline((0, 0), (0, 40), (20, 80), (40, 80), (40, 0), (0, 0)) + # Create a filled face from the perimeter drawing + make_face() + # Extrude to create the basis controller shape + extrude(amount=30, both=True) + # Round off all the edges + fillet(controller.edges(), radius=3) + # Hollow out the controller + offset(amount=-1, mode=Mode.SUBTRACT) + # Extract the face that will house the display + display_face = ( + controller.faces() + .filter_by(GeomType.PLANE) + .filter_by_position(Axis.Z, 50, 70)[0] + ) + # Create a workplane from the face + display_workplane = Plane( + origin=display_face.center(), x_dir=(1, 0, 0), z_dir=display_face.normal_at() + ) + # Place the sketch directly on the controller + with BuildSketch(display_workplane) as display: + RectangleRounded(40, 30, 2) + with GridLocations(45, 35, 2, 2): + Circle(1) + # Cut the display sketch through the controller + extrude(amount=-1, mode=Mode.SUBTRACT) +# [Ex. 14] +visible, hidden = controller.part.project_to_viewport((70, -50, 120)) +max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) +exporter = ExportSVG(scale=100 / max_dimension) +exporter.add_layer("Visible") +exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) +exporter.add_shape(visible, layer="Visible") +exporter.add_shape(hidden, layer="Hidden") +exporter.write(f"assets/controller.svg") + + +# [Align] +with BuildSketch() as align: + with GridLocations(1, 1, 2, 2): + Circle(0.5) + Circle(0.49, mode=Mode.SUBTRACT) + with GridLocations(1, 1, 1, 2): + Circle(0.5) + Circle(0.49, mode=Mode.SUBTRACT) + with GridLocations(1, 1, 2, 1): + Circle(0.5) + Circle(0.49, mode=Mode.SUBTRACT) + with Locations((0, 0)): + Circle(0.5) + Circle(0.49, mode=Mode.SUBTRACT) + + # Top Right: (MIN, MIN) + with Locations((0.75, 0.75)): + Text("MIN\nMIN", font="FreeSerif", font_size=0.07) + # Top Center: (CENTER, MIN) + with Locations((0.0, 0.75 + 0.07 / 2)): + Text("CENTER", font="FreeSerif", font_size=0.07) + with Locations((0.0, 0.75 - 0.07 / 2)): + Text("MIN", font="FreeSerif", font_size=0.07) + # Top Left: (MAX, MIN) + with Locations((-0.75, 0.75 + 0.07 / 2)): + Text("MAX", font="FreeSerif", font_size=0.07) + with Locations((-0.75, 0.75 - 0.07 / 2)): + Text("MIN", font="FreeSerif", font_size=0.07) + # Center Right: (MIN, CENTER) + with Locations((0.75, 0.07 / 2)): + Text("MIN", font="FreeSerif", font_size=0.07) + with Locations((0.75, -0.07 / 2)): + Text("CENTER", font="FreeSerif", font_size=0.07) + # Center: (CENTER, CENTER) + with Locations((0, 0)): + Text("CENTER\nCENTER", font="FreeSerif", font_size=0.07) + # Center Left: (MAX, CENTER) + with Locations((-0.75, 0.07 / 2)): + Text("MAX", font="FreeSerif", font_size=0.07) + with Locations((-0.75, -0.07 / 2)): + Text("CENTER", font="FreeSerif", font_size=0.07) + # Bottom Right: (MIN, MAX) + with Locations((0.75, -0.75 + 0.07 / 2)): + Text("MIN", font="FreeSerif", font_size=0.07) + with Locations((0.75, -0.75 - 0.07 / 2)): + Text("MAX", font="FreeSerif", font_size=0.07) + # Bottom Center: (CENTER, MAX) + with Locations((0.0, -0.75 + 0.07 / 2)): + Text("MAX", font="FreeSerif", font_size=0.07) + with Locations((0.0, -0.75 - 0.07 / 2)): + Text("CENTER", font="FreeSerif", font_size=0.07) + # Bottom Left: (MAx, MAX) + with Locations((-0.75, -0.75)): + Text("MAX\nMAX", font="FreeSerif", font_size=0.07) + +s = 100 / max(*align.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(align.sketch) +svg.write("assets/align.svg") + +# [DimensionLine] +std = Draft() +with BuildSketch() as d_line: + Rectangle(100, 100) + c = Circle(45, mode=Mode.SUBTRACT) + DimensionLine([c.edge() @ 0, c.edge() @ 0.5], draft=std) +s = 100 / max(*d_line.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(d_line.sketch) +svg.write("assets/d_line.svg") + +# [ExtensionLine] +with BuildSketch() as e_line: + with BuildLine(): + l1 = Polyline((20, 40), (-40, 40), (-40, -40), (20, -40)) + RadiusArc(l1 @ 0, l1 @ 1, 50) + make_face() + ExtensionLine(border=e_line.edges().sort_by(Axis.X)[0], offset=10, draft=std) + outside_curve = e_line.edges().sort_by(Axis.X)[-1] + ExtensionLine(border=outside_curve, offset=10, label_angle=True, draft=std) +s = 100 / max(*e_line.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(e_line.sketch) +svg.write("assets/e_line.svg") + +# [TechnicalDrawing] +with BuildSketch() as tech_drawing: + with Locations((0, 20)): + add(e_line) + TechnicalDrawing() +s = 100 / max(*tech_drawing.sketch.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(tech_drawing.sketch) +svg.write("assets/tech_drawing.svg") + +# [ArrowHead] +arrow_head = ArrowHead(10) +s = 100 / max(*arrow_head.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(arrow_head) +svg.write("assets/arrow_head.svg") + +# [Arrow] +arrow = Arrow( + 10, shaft_path=Edge.make_circle(100, start_angle=0, end_angle=10), shaft_width=1 +) +s = 100 / max(*arrow.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_shape(arrow) +svg.write("assets/arrow.svg") diff --git a/docs/objects_3d.py b/docs/objects_3d.py new file mode 100644 index 00000000..192a69ac --- /dev/null +++ b/docs/objects_3d.py @@ -0,0 +1,79 @@ +# [Setup] +from build123d import * + +# [Setup] + + +def write_svg(filename: str, view_port_origin=(-100, -50, 30)): + """Save an image of the BuildPart object as SVG""" + builder: BuildPart = BuildPart._get_context() + + visible, hidden = builder.part.project_to_viewport(view_port_origin) + max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) + exporter = ExportSVG(scale=100 / max_dimension) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write(f"assets/{filename}.svg") + + +# [Ex. 1] +with BuildPart() as example_1: + Box(3, 2, 1) + # [Ex. 1] + write_svg("box_example") + +# [Ex. 2] +with BuildPart() as example_2: + Cone(2, 1, 2) + # [Ex. 2] + write_svg("cone_example") + +# [Ex. 3] +with BuildPart() as example_3: + Box(3, 2, 1) + with Locations(example_3.faces().sort_by(Axis.Z)[-1]): + CounterBoreHole(0.2, 0.4, 0.5, 0.9) + # [Ex. 3] + write_svg("counter_bore_hole_example") + + +# [Ex. 4] +with BuildPart() as example_4: + Box(3, 2, 1) + with Locations(example_3.faces().sort_by(Axis.Z)[-1]): + CounterSinkHole(0.2, 0.4, 0.9) + # [Ex. 4] + write_svg("counter_sink_hole_example") + +# [Ex. 5] +with BuildPart() as example_5: + Cylinder(1, 2) + # [Ex. 5] + write_svg("cylinder_example") + +# [Ex. 6] +with BuildPart() as example_6: + Box(3, 2, 1) + Hole(0.4) + # [Ex. 6] + write_svg("hole_example") + +# [Ex. 7] +with BuildPart() as example_7: + Sphere(1, 0) + # [Ex. 7] + write_svg("sphere_example") + +# [Ex. 8] +with BuildPart() as example_8: + Torus(1, 0.2) + # [Ex. 8] + write_svg("torus_example") + +# [Ex. 9] +with BuildPart() as example_9: + Wedge(1, 1, 1, 0, 0, 0.5, 0.5) + # [Ex. 9] + write_svg("wedge_example") diff --git a/docs/operations.rst b/docs/operations.rst new file mode 100644 index 00000000..0efb2d43 --- /dev/null +++ b/docs/operations.rst @@ -0,0 +1,89 @@ +########## +Operations +########## + +Operations are functions that take objects as inputs and transform them into new objects. For example, a 2D Sketch can be extruded to create a 3D Part. All operations are Python functions which can be applied using both the Algebra and Builder APIs. It's important to note that objects created by operations are not affected by ``Locations``, meaning their position is determined solely by the input objects used in the operation. + +Here are a couple ways to use :func:`~operations_part.extrude`, in Builder and Algebra mode: + +.. code-block:: python + + with BuildPart() as cylinder: + with BuildSketch(): + Circle(radius) + extrude(amount=height) + +.. code-block:: python + + cylinder = extrude(Circle(radius), amount=height) + +The following table summarizes all of the available operations. Operations marked as 1D are +applicable to BuildLine and Algebra Curve, 2D to BuildSketch and Algebra Sketch, 3D to +BuildPart and Algebra Part. + ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| Operation | Description | 0D | 1D | 2D | 3D | Example | ++==============================================+====================================+====+====+====+====+========================+ +| :func:`~operations_generic.add` | Add object to builder | | ✓ | ✓ | ✓ | :ref:`16 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.bounding_box` | Add bounding box as Shape | | ✓ | ✓ | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.chamfer` | Bevel Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.extrude` | Draw 2D Shape into 3D | | | | ✓ | :ref:`3 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.fillet` | Radius Vertex or Edge | | | ✓ | ✓ | :ref:`9 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.loft` | Create 3D Shape from sections | | | | ✓ | :ref:`24 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.make_brake_formed` | Create sheet metal parts | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_sketch.make_face` | Create a Face from Edges | | | ✓ | | :ref:`4 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_sketch.make_hull` | Create Convex Hull from Edges | | | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.mirror` | Mirror about Plane | | ✓ | ✓ | ✓ | :ref:`15 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.offset` | Inset or outset Shape | | ✓ | ✓ | ✓ | :ref:`25 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.project` | Project points, lines or Faces | ✓ | ✓ | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.project_workplane` | Create workplane for projection | | | | | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.revolve` | Swing 2D Shape about Axis | | | | ✓ | :ref:`23 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.scale` | Change size of Shape | | ✓ | ✓ | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.section` | Generate 2D slices from 3D Shape | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.split` | Divide object by Plane | | ✓ | ✓ | ✓ | :ref:`27 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_generic.sweep` | Extrude 1/2D section(s) along path | | | ✓ | ✓ | :ref:`14 ` | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_part.thicken` | Expand 2D section(s) | | | | ✓ | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ +| :func:`~operations_sketch.trace` | Convert lines to faces | | | ✓ | | | ++----------------------------------------------+------------------------------------+----+----+----+----+------------------------+ + +Reference +^^^^^^^^^ +.. autoclass:: operations_generic.add +.. autoclass:: operations_generic.bounding_box +.. autoclass:: operations_generic.chamfer +.. autoclass:: operations_part.extrude +.. autoclass:: operations_generic.fillet +.. autoclass:: operations_part.loft +.. autoclass:: operations_part.make_brake_formed +.. autoclass:: operations_sketch.make_face +.. autoclass:: operations_sketch.make_hull +.. autoclass:: operations_generic.mirror +.. autoclass:: operations_generic.offset +.. autoclass:: operations_generic.project +.. autoclass:: operations_part.project_workplane +.. autoclass:: operations_part.revolve +.. autoclass:: operations_generic.scale +.. autoclass:: operations_part.section +.. autoclass:: operations_generic.split +.. autoclass:: operations_generic.sweep +.. autoclass:: operations_part.thicken +.. autoclass:: operations_sketch.trace diff --git a/docs/rigid_joints_pipe.py b/docs/rigid_joints_pipe.py new file mode 100644 index 00000000..a52ce814 --- /dev/null +++ b/docs/rigid_joints_pipe.py @@ -0,0 +1,26 @@ +import copy +from build123d import * +from bd_warehouse.flange import WeldNeckFlange +from bd_warehouse.pipe import PipeSection +from ocp_vscode import * + +flange_inlet = WeldNeckFlange(nps="10", flange_class=300) +flange_outlet = copy.copy(flange_inlet) + +with BuildPart() as pipe_builder: + # Create the pipe + with BuildLine(): + path = TangentArc((0, 0, 0), (2 * FT, 0, 1 * FT), tangent=(1, 0, 0)) + with BuildSketch(Plane(origin=path @ 0, z_dir=path % 0)): + PipeSection("10", material="stainless", identifier="40S") + sweep() + + # Add the joints + RigidJoint(label="inlet", joint_location=-path.location_at(0)) + RigidJoint(label="outlet", joint_location=path.location_at(1)) + +# Place the flanges at the ends of the pipe +pipe_builder.part.joints["inlet"].connect_to(flange_inlet.joints["pipe"]) +pipe_builder.part.joints["outlet"].connect_to(flange_outlet.joints["pipe"]) + +show(pipe_builder, flange_inlet, flange_outlet, render_joints=True) diff --git a/docs/rod_end.py b/docs/rod_end.py new file mode 100644 index 00000000..9d684fb8 --- /dev/null +++ b/docs/rod_end.py @@ -0,0 +1,52 @@ +from build123d import * +from bd_warehouse.thread import IsoThread +from ocp_vscode import * + +# Create the thread so the min radius is available below +thread = IsoThread( + major_diameter=8, pitch=1.25, length=20, end_finishes=("fade", "raw") +) + +with BuildPart() as rod_end: + # Create the outer shape + with BuildSketch(): + Circle(22.25 / 2) + with Locations((0, -12)): + Rectangle(8, 1) + make_hull() + split(bisect_by=Plane.YZ) + revolve(axis=Axis.Y) + # Refine the shape + with BuildSketch(Plane.YZ) as s2: + Rectangle(25, 8, align=(Align.MIN, Align.CENTER)) + Rectangle(9, 10, align=(Align.MIN, Align.CENTER)) + chamfer(s2.vertices(), 0.5) + revolve(axis=Axis.Z, mode=Mode.INTERSECT) + # Add the screw shaft + Cylinder( + thread.min_radius, + 30, + rotation=(90, 0, 0), + align=(Align.CENTER, Align.CENTER, Align.MIN), + ) + # Cutout the ball socket + Sphere(15.89 / 2, mode=Mode.SUBTRACT) # Add thread + with Locations((0, -30, 0)): + add(thread, rotation=(-90, 0, 0)) + # Create the ball joint + BallJoint( + "socket", + joint_location=Location(), + angular_range=((-14, 14), (-14, 14), (0, 360)), + ) + +with BuildPart() as ball: + Sphere(15.88 / 2) + Box(50, 50, 13, mode=Mode.INTERSECT) + Hole(4) + ball.part.color = Color("aliceblue") + RigidJoint("ball", joint_location=Location()) + +rod_end.part.joints["socket"].connect_to(ball.part.joints["ball"], angles=(5, 10, 0)) + +show(rod_end.part, ball.part) diff --git a/docs/selector_after.svg b/docs/selector_after.svg deleted file mode 100644 index cdd6ef8f..00000000 --- a/docs/selector_after.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/selector_before.svg b/docs/selector_before.svg deleted file mode 100644 index 6ca2a114..00000000 --- a/docs/selector_before.svg +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/selector_example.py b/docs/selector_example.py index d6539a3b..ea72549e 100644 --- a/docs/selector_example.py +++ b/docs/selector_example.py @@ -25,9 +25,8 @@ limitations under the License. """ from build123d import * +from ocp_vscode import * -# SVG Export options -svg_opts = {"pixel_scale": 50, "show_axes": False, "show_hidden": True} with BuildPart() as example: Cylinder(radius=10, height=3) @@ -35,9 +34,14 @@ RegularPolygon(radius=7, side_count=6) Circle(radius=4, mode=Mode.SUBTRACT) extrude(amount=-2, mode=Mode.SUBTRACT) - example.part.export_svg( - "selector_before.svg", (-100, 100, 150), (0, 0, 1), svg_opts=svg_opts - ) + visible, hidden = example.part.project_to_viewport((-100, 100, 100)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/selector_before.svg") + fillet( example.edges() .filter_by(GeomType.CIRCLE) @@ -45,9 +49,13 @@ .sort_by(Axis.Z)[-1], radius=1, ) - example.part.export_svg( - "selector_after.svg", (-100, 100, 150), (0, 0, 1), svg_opts=svg_opts - ) -if "show_object" in locals(): - show_object(example.part.wrapped, name="part") +visible, hidden = example.part.project_to_viewport((-100, 100, 100)) +exporter = ExportSVG(scale=6) +exporter.add_layer("Visible") +exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) +exporter.add_shape(visible, layer="Visible") +exporter.add_shape(hidden, layer="Hidden") +exporter.write("assets/selector_after.svg") + +show(example) diff --git a/docs/selectors.rst b/docs/selectors.rst index 6e020745..5dfa1635 100644 --- a/docs/selectors.rst +++ b/docs/selectors.rst @@ -79,3 +79,16 @@ sorts and filters. Here is an example of a custom filters: and -overall_width / 2 < v.X < overall_width / 2, din.vertices(), ) + +The :meth:`~topology.ShapeList.filter_by` method can take lambda expressions as part of a +fluent chain of operations which enables integration of custom filters into a larger change of +selectors as shown in this example: + +.. code-block:: python + + obj = Box(1, 1, 1) - Cylinder(0.2, 1) + faces_with_holes = obj.faces().filter_by(lambda f: f.inner_wires()) + +.. image:: assets/custom_selector.png + +Here the two faces with "inner_wires" (i.e. holes) have been selected independent of orientation. \ No newline at end of file diff --git a/docs/slide_latch.py b/docs/slide_latch.py new file mode 100644 index 00000000..1b70e04e --- /dev/null +++ b/docs/slide_latch.py @@ -0,0 +1,59 @@ +from build123d import * +from ocp_vscode import * + +with BuildPart() as latch: + # Basic box shape to start with filleted corners + Box(70, 30, 14) + end = latch.faces().sort_by(Axis.X)[-1] # save the end with the hole + fillet(latch.edges().filter_by(Axis.Z), 2) + fillet(latch.edges().sort_by(Axis.Z)[-1], 1) + # Make screw tabs + with BuildSketch(latch.faces().sort_by(Axis.Z)[0]) as l4: + with Locations((-30, 0), (30, 0)): + SlotOverall(50, 10, rotation=90) + Rectangle(50, 30) + fillet(l4.vertices(Select.LAST), radius=2) + extrude(amount=-2) + with GridLocations(60, 40, 2, 2): + Hole(2) + # Create the hole from the end saved previously + with BuildSketch(end) as slide_hole: + add(end) + offset(amount=-2) + fillet(slide_hole.vertices(), 1) + extrude(amount=-68, mode=Mode.SUBTRACT) + # Slot for the hangle to slide in + with BuildSketch(latch.faces().sort_by(Axis.Z)[-1]): + SlotOverall(32, 8) + extrude(amount=-2, mode=Mode.SUBTRACT) + # The slider will move align the x axis 12mm in each direction + LinearJoint("latch", axis=Axis.X, linear_range=(-12, 12)) + +with BuildPart() as slide: + # The slide will be a little smaller than the hole + with BuildSketch() as s1: + add(slide_hole.sketch) + offset(amount=-0.25) + # The extrusions aren't symmetric + extrude(amount=46) + extrude(slide.faces().sort_by(Axis.Z)[0], amount=20) + # Round off the ends + fillet(slide.edges().group_by(Axis.Z)[0], 1) + fillet(slide.edges().group_by(Axis.Z)[-1], 1) + # Create the knob + with BuildSketch() as s2: + with Locations((12, 0)): + SlotOverall(15, 4, rotation=90) + Rectangle(12, 7, align=(Align.MIN, Align.CENTER)) + fillet(s2.vertices(Select.LAST), 1) + split(bisect_by=Plane.XZ) + revolve(axis=Axis.X) + # Align the joint to Plane.ZY flipped + RigidJoint("slide", joint_location=Location(-Plane.ZY)) + +# Position the slide in the latch: -12 >= position <= 12 +latch.part.joints["latch"].connect_to(slide.part.joints["slide"], position=12) + +# show(latch.part, render_joints=True) +# show(slide.part, render_joints=True) +show(latch.part, slide.part, render_joints=True) diff --git a/docs/tips.rst b/docs/tips.rst new file mode 100644 index 00000000..b5064d2a --- /dev/null +++ b/docs/tips.rst @@ -0,0 +1,138 @@ +##################### +Tips & Best Practices +##################### + +Although there are countless ways to create objects with build123d, experience +has proven that certain techniques can assist designers in achieving their goals +with the greatest efficiency. The following is a description of these techniques. + +************************* +Can't Get There from Here +************************* + +Unfortunately, it's a reality that not all parts described using build123d can be +successfully constructed by the underlying CAD core. Designers may have to +explore different design approaches to get the OpenCascade CAD core to successfully +build the target object. For instance, if a multi-section :func:`~operations_generic.sweep` +operation fails, a :func:`~operations_part.loft` operation may be a viable alternative +in certain situations. It's crucial to remember that CAD is a complex field and +patience may be required to achieve the desired results. + +************ +2D before 3D +************ + +When creating complex 3D objects, it is generally best to start with 2D work before +moving on to 3D. This is because 3D structures are much more intricate, and 3D operations +can be slower and more prone to failure. For designers who come from a Constructive Solid +Geometry (CSG) background, such as OpenSCAD, this approach may seem counterintuitive. On +the other hand, designers from a GUI BREP CAD background, like Fusion 360 or SolidWorks, +may find this approach more natural. + +In practice, this means that 3D objects are often created by applying operations like +:func:`~operations_part.extrude` or :func:`~operations_part.revolve` to 2D sketches, as shown below: + +.. code:: python + + with BuildPart() as my_part: + with BuildSketch() as part_profile: + ... + extrude(amount=some_distance) + ... + +With this structure ``part_profile`` may have many objects that are combined and +modified by operations like :func:`~operations_generic.fillet` before being extruded +to a 3D shape. + +************************** +Delay Chamfers and Fillets +************************** + +Chamfers and fillets can add complexity to a design by transforming simple vertices +or edges into arcs or non-planar faces. This can significantly increase the complexity +of the design. To avoid unnecessary processing costs and potential errors caused by a +needlessly complicated design, it's recommended to perform these operations towards +the end of the object's design. This is especially true for 3D shapes, as it is +sometimes necessary to fillet or chamfer in the 2D design phase. Luckily, these +2D fillets and chamfers are less likely to fail than their 3D counterparts. + +************ +Parameterize +************ + +One of the most powerful features of build123d is the ability to design fully +parameterized parts. While it may be faster to use a GUI CAD package for the +initial iteration of a part, subsequent iterations can prove frustratingly +difficult. By using variables for critical dimensions and deriving other dimensions +from these key variables, not only can a single part be created, but a whole set +of parts can be readily available. When inevitable change requests arise, a simple +parameter adjustment may be all that's required to make necessary modifications. + +****************** +Use Shallow Copies +****************** + +As discussed in the Assembly section, a :ref:`shallow copy ` of parts that +are repeated in your design can make a huge difference in performance and usability of +your end design. Objects like fasteners, bearings, chain links, etc. could be duplicated +tens or even hundreds of times otherwise. Use shallow copies where possible but keep in +mind that if one instance of the object changes all will change. + +**************** +Object Selection +**************** + +When selecting features in a design it's sometimes easier to select an object from +higher up in the topology first then select the object from there. For example let's +consider a plate with four chamfered hole like this: + +.. image:: assets/plate.svg + :align: center + +When selecting edges to be chamfered one might first select the face that these edges +belongs to then select the edges as shown here: + +.. code-block:: python + + from build123d import * + + svg_opts = {"pixel_scale": 5, "show_axes": False, "show_hidden": True} + + length, width, thickness = 80.0, 60.0, 10.0 + hole_dia = 6.0 + + with BuildPart() as plate: + Box(length, width, thickness) + with GridLocations(length - 20, width - 20, 2, 2): + Hole(radius=hole_dia / 2) + top_face: Face = plate.faces().sort_by(Axis.Z)[-1] + hole_edges = top_face.edges().filter_by(GeomType.CIRCLE) + chamfer(hole_edges, length=1) + +******************************** +Build123d - CadQuery Integration +******************************** + +As both `CadQuery `_ and **build123d** use +a common OpenCascade Python wrapper (`OCP `_) it's possible to +interchange objects between the two systems by transferring the ``wrapped`` objects as follows: + +.. code-block:: python + + import build123d as b3d + b3d_solid = b3d.Solid.make_box(1,1,1) + + ... some cadquery stuff ... + + b3d_solid.wrapped = cq_solid.wrapped + + +***************** +Self Intersection +***************** + +Avoid creating objects that intersect themselves - even if at a single vertex - as these topoplogies +will almost certainly be invalid (even if :meth:`~topology.Shape.is_valid` reports a ``True`` value). +An example of where this my arise is with the thread of a screw (or any helical shape) where after +one complete revolution the part may contact itself. One is likely be more successful if the part +is split into multiple sections - say 180° of a helix - which are then stored in an assembly. diff --git a/docs/tutorial_joints.py b/docs/tutorial_joints.py index 07e7affc..5e90027b 100644 --- a/docs/tutorial_joints.py +++ b/docs/tutorial_joints.py @@ -26,6 +26,7 @@ """ # [import] from build123d import * +from ocp_vscode import * # [Hinge Class] @@ -119,7 +120,7 @@ def __init__( add(hinge_profile.part, rotation=(90, 0, 0), mode=Mode.INTERSECT) # Create holes for fasteners - with Workplanes(leaf_builder.part.faces().filter_by(Axis.Y)[-1]): + with Locations(leaf_builder.part.faces().filter_by(Axis.Y)[-1]): with GridLocations(0, length / 3, 1, 3): holes = CounterSinkHole(3 * MM, 5 * MM) # Add the hinge pin to the external leaf @@ -132,7 +133,6 @@ def __init__( # Leaf attachment RigidJoint( label="leaf", - to_part=leaf_builder.part, joint_location=Location( (width - barrel_diameter, 0, length / 2), (90, 0, 0) ), @@ -141,13 +141,13 @@ def __init__( if inner: RigidJoint( "hinge_axis", - leaf_builder.part, - Location((width - barrel_diameter / 2, barrel_diameter / 2, 0)), + joint_location=Location( + (width - barrel_diameter / 2, barrel_diameter / 2, 0) + ), ) else: RevoluteJoint( "hinge_axis", - leaf_builder.part, axis=Axis( (width - barrel_diameter / 2, barrel_diameter / 2, 0), (0, 0, 1) ), @@ -158,13 +158,11 @@ def __init__( for hole, hole_location in enumerate(hole_locations): CylindricalJoint( label="hole" + str(hole), - to_part=leaf_builder.part, axis=hole_location.to_axis(), linear_range=(-2 * CM, 2 * CM), angular_range=(0, 360), ) # [End Fastener holes] - super().__init__(leaf_builder.part.wrapped, joints=leaf_builder.part.joints) # [Hinge Class] @@ -194,17 +192,15 @@ def __init__( with Locations((-15 * CM, 0, 5 * CM)): Box(2 * CM, 12 * CM, 4 * MM, mode=Mode.SUBTRACT) bbox = box.bounding_box() - with Workplanes( + with Locations( Plane(origin=(bbox.min.X, 0, bbox.max.Z - 30 * MM), z_dir=(-1, 0, 0)) ): with GridLocations(0, 40 * MM, 1, 3): Hole(3 * MM, 1 * CM) RigidJoint( "hinge_attachment", - box_builder.part, - Location((-15 * CM, 0, 4 * CM), (180, 90, 0)), + joint_location=Location((-15 * CM, 0, 4 * CM), (180, 90, 0)), ) - # [Demonstrate that objects with Joints can be moved and the joints follow] box = box_builder.part.moved(Location((0, 0, 5 * CM))) @@ -216,8 +212,7 @@ def __init__( Hole(3 * MM, 1 * CM) RigidJoint( "hinge_attachment", - lid_builder.part, - Location((0, 0, 0), (0, 0, 180)), + joint_location=Location((0, 0, 0), (0, 0, 180)), ) lid = lid_builder.part @@ -226,85 +221,93 @@ def __init__( m6_joint = RigidJoint("head", m6_screw, Location((0, 0, 0), (0, 0, 0))) # [End of screw creation] + # [Export SVG files] +def write_svg(part, filename: str, view_port_origin=(-100, 100, 150)): + """Save an image of the BuildPart object as SVG""" + visible, hidden = part.project_to_viewport(view_port_origin) + max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) + exporter = ExportSVG(scale=100 / max_dimension) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write(f"assets/{filename}.svg") + + # # SVG Export options -svg_opts = {"pixel_scale": 5, "show_axes": False, "show_hidden": True} -Compound.make_compound([box, box.joints["hinge_attachment"].symbol]).export_svg( - "assets/tutorial_joint_box.svg", (-100, 100, 150), (0, 0, 1), svg_opts=svg_opts +write_svg( + Compound.make_compound([box, box.joints["hinge_attachment"].symbol]), + "tutorial_joint_box", ) -Compound.make_compound( - [ - hinge_inner, - hinge_inner.joints["leaf"].symbol, - hinge_inner.joints["hinge_axis"].symbol, - hinge_inner.joints["hole0"].symbol, - hinge_inner.joints["hole1"].symbol, - hinge_inner.joints["hole2"].symbol, - ] -).export_svg( - "assets/tutorial_joint_inner_leaf.svg", +write_svg( + Compound.make_compound( + [ + hinge_inner, + hinge_inner.joints["leaf"].symbol, + hinge_inner.joints["hinge_axis"].symbol, + hinge_inner.joints["hole0"].symbol, + hinge_inner.joints["hole1"].symbol, + hinge_inner.joints["hole2"].symbol, + ] + ), + "tutorial_joint_inner_leaf", (100, 100, -50), - (0, 0, 1), - svg_opts=svg_opts, ) -Compound.make_compound( - [ - hinge_outer, - hinge_outer.joints["leaf"].symbol, - hinge_outer.joints["hinge_axis"].symbol, - hinge_outer.joints["hole0"].symbol, - hinge_outer.joints["hole1"].symbol, - hinge_outer.joints["hole2"].symbol, - ] -).export_svg( - "assets/tutorial_joint_outer_leaf.svg", +write_svg( + Compound.make_compound( + [ + hinge_outer, + hinge_outer.joints["leaf"].symbol, + hinge_outer.joints["hinge_axis"].symbol, + hinge_outer.joints["hole0"].symbol, + hinge_outer.joints["hole1"].symbol, + hinge_outer.joints["hole2"].symbol, + ] + ), + "tutorial_joint_outer_leaf", (100, 100, -50), - (0, 0, 1), - svg_opts=svg_opts, ) -Compound.make_compound([box, hinge_outer]).export_svg( - "assets/tutorial_joint_box_outer.svg", +write_svg( + Compound.make_compound([box, hinge_outer]), + "tutorial_joint_box_outer", (-100, -100, 50), - (0, 0, 1), - svg_opts=svg_opts, ) -Compound.make_compound([lid, lid.joints["hinge_attachment"].symbol]).export_svg( - "assets/tutorial_joint_lid.svg", (-100, 100, 150), (0, 0, 1), svg_opts=svg_opts +write_svg( + Compound.make_compound([lid, lid.joints["hinge_attachment"].symbol]), + "tutorial_joint_lid", + (-100, 100, 150), ) -Compound.make_compound([m6_screw, m6_joint.symbol]).export_svg( - "assets/tutorial_joint_m6_screw.svg", +write_svg( + Compound.make_compound([m6_screw, m6_joint.symbol]), + "tutorial_joint_m6_screw", (-100, 100, 150), - (0, 0, 1), - svg_opts={"pixel_scale": 20, "show_axes": False, "show_hidden": False}, ) # [Connect Box to Outer Hinge] box.joints["hinge_attachment"].connect_to(hinge_outer.joints["leaf"]) # [Connect Box to Outer Hinge] -Compound.make_compound([box, hinge_outer]).export_svg( - "assets/tutorial_joint_box_outer.svg", +write_svg( + Compound.make_compound([box, hinge_outer]), + "tutorial_joint_box_outer", (-100, -100, 50), - (0, 0, 1), - svg_opts=svg_opts, ) # [Connect Hinge Leaves] hinge_outer.joints["hinge_axis"].connect_to(hinge_inner.joints["hinge_axis"], angle=120) # [Connect Hinge Leaves] -Compound.make_compound([box, hinge_outer, hinge_inner]).export_svg( - "assets/tutorial_joint_box_outer_inner.svg", +write_svg( + Compound.make_compound([box, hinge_outer, hinge_inner]), + "tutorial_joint_box_outer_inner", (-100, -100, 50), - (0, 0, 1), - svg_opts=svg_opts, ) # [Connect Hinge to Lid] hinge_inner.joints["leaf"].connect_to(lid.joints["hinge_attachment"]) # [Connect Hinge to Lid] -Compound.make_compound([box, hinge_outer, hinge_inner, lid]).export_svg( - "assets/tutorial_joint_box_outer_inner_lid.svg", +write_svg( + Compound.make_compound([box, hinge_outer, hinge_inner, lid]), + "tutorial_joint_box_outer_inner_lid", (-100, -100, 50), - (0, 0, 1), - svg_opts=svg_opts, ) # [Connect Screw to Hole] hinge_outer.joints["hole2"].connect_to(m6_joint, position=5 * MM, angle=30) @@ -335,31 +338,28 @@ def __init__( print(f"{children} by {volume:0.3f} mm^3") # [Export Final SVG file] -box_assembly.export_svg( - "assets/tutorial_joint.svg", (-100, -100, 50), (0, 0, 1), svg_opts=svg_opts -) - - -if "show_object" in locals(): - show_object(box, name="box", options={"alpha": 0.8}) - # show_object(box.joints["hinge_attachment"].symbol, name="box attachment point") - show_object(hinge_outer, name="hinge_outer") - # show_object(hinge_outer.joints["leaf"].symbol, name="hinge_outer leaf joint") - # show_object(hinge_outer.joints["hinge_axis"].symbol, name="hinge_outer hinge axis") - show_object(lid, name="lid") - # show_object(lid.joints["hinge_attachment"].symbol, name="lid attachment point") - show_object(hinge_inner, name="hinge_inner") - # show_object(hinge_inner.joints["leaf"].symbol, name="hinge_inner leaf joint") - # show_object(hinge_inner.joints["hinge_axis"].symbol, name="hinge_inner hinge axis") - for hole in [0, 1, 2]: - show_object( - hinge_inner.joints["hole" + str(hole)].symbol, - name="hinge_inner hole " + str(hole), - ) - show_object( - hinge_outer.joints["hole" + str(hole)].symbol, - name="hinge_outer hole " + str(hole), - ) - show_object(m6_screw, name="m6 screw") - show_object(m6_joint.symbol, name="m6 screw symbol") - show_object(box_assembly, name="box assembly") +write_svg(box_assembly, "tutorial_joint", (-100, -100, 50)) + + +show_object(box, name="box", options={"alpha": 0.8}) +# show_object(box.joints["hinge_attachment"].symbol, name="box attachment point") +show_object(hinge_outer, name="hinge_outer") +# show_object(hinge_outer.joints["leaf"].symbol, name="hinge_outer leaf joint") +# show_object(hinge_outer.joints["hinge_axis"].symbol, name="hinge_outer hinge axis") +show_object(lid, name="lid") +# show_object(lid.joints["hinge_attachment"].symbol, name="lid attachment point") +show_object(hinge_inner, name="hinge_inner") +# show_object(hinge_inner.joints["leaf"].symbol, name="hinge_inner leaf joint") +# show_object(hinge_inner.joints["hinge_axis"].symbol, name="hinge_inner hinge axis") +for hole in [0, 1, 2]: + show_object( + hinge_inner.joints["hole" + str(hole)].symbol, + name="hinge_inner hole " + str(hole), + ) + show_object( + hinge_outer.joints["hole" + str(hole)].symbol, + name="hinge_outer hole " + str(hole), + ) +show_object(m6_screw, name="m6 screw") +show_object(m6_joint.symbol, name="m6 screw symbol") +show_object(box_assembly, name="box assembly") diff --git a/docs/tutorial_joints.rst b/docs/tutorial_joints.rst index 8ac3e6e9..f3b90cd2 100644 --- a/docs/tutorial_joints.rst +++ b/docs/tutorial_joints.rst @@ -5,31 +5,7 @@ Joint Tutorial ############## This tutorial provides a step by step guide in using :class:`~topology.Joint`'s as we create -a box with a hinged lid. They allow Solid and Compound objects to be arranged -relative to each other in an intuitive manner - with the same degree of motion -that is found with the equivalent physical joints. :class:`~topology.Joint`'s always work -in pairs - a :class:`~topology.Joint` can only be connected to another :class:`~topology.Joint` as follows: - -+---------------------------------------+---------------------------------------------------------------------+--------------------+ -| :class:`~topology.Joint` | connect_to | Example | -+=======================================+=====================================================================+====================+ -| :class:`~topology.BallJoint` | :class:`~topology.RigidJoint` | Gimbal | -+---------------------------------------+---------------------------------------------------------------------+--------------------+ -| :class:`~topology.CylindricalJoint` | :class:`~topology.RigidJoint` | Screw | -+---------------------------------------+---------------------------------------------------------------------+--------------------+ -| :class:`~topology.LinearJoint` | :class:`~topology.RigidJoint`, :class:`~topology.RevoluteJoint` | Slider or Pin Slot | -+---------------------------------------+---------------------------------------------------------------------+--------------------+ -| :class:`~topology.RevoluteJoint` | :class:`~topology.RigidJoint` | Hinge | -+---------------------------------------+---------------------------------------------------------------------+--------------------+ -| :class:`~topology.RigidJoint` | :class:`~topology.RigidJoint` | Fixed | -+---------------------------------------+---------------------------------------------------------------------+--------------------+ - -Objects may have many joints bound to them each with an identifying label. All :class:`~topology.Joint` -objects have a ``symbol`` property that can be displayed to help visualize -their position and orientation. - -In this tutorial, a box with a hinged lid will be created to illustrate the -use of three different :class:`~topology.Joint` types. +a box with a hinged lid to illustrate the use of three different :class:`~topology.Joint` types. .. image:: assets/tutorial_joint.svg :align: center @@ -104,7 +80,7 @@ The second joint to add is either a :class:`~topology.RigidJoint` (on the inner .. literalinclude:: tutorial_joints.py :start-after: [Create the Joints] :end-before: [Fastener holes] - :emphasize-lines: 10-25 + :emphasize-lines: 10-24 The inner leaf just pivots around the outer leaf and therefore the simple :class:`~topology.RigidJoint` is used to define the Location of this pivot. The outer leaf contains the more complex @@ -160,11 +136,12 @@ The box is created with :class:`~build_part.BuildPart` as a simple object - as s the joint used to attach the outer hinge leaf. .. image:: assets/tutorial_joint_box.svg + :align: center .. literalinclude:: tutorial_joints.py :start-after: [Create the box with a RigidJoint to mount the hinge] :end-before: [Demonstrate that objects with Joints can be moved and the joints follow] - :emphasize-lines: 13-17 + :emphasize-lines: 13-16 Since the hinge will be fixed to the box another :class:`~topology.RigidJoint` is used mark where the hinge will go. Note that the orientation of this :class:`~topology.Joint` will control how the hinge leaf is @@ -190,11 +167,12 @@ Step 5: Create the Lid Much like the box, the lid is created in a :class:`~build_part.BuildPart` context and is assigned a :class:`~topology.RigidJoint`. .. image:: assets/tutorial_joint_lid.svg + :align: center .. literalinclude:: tutorial_joints.py :start-after: [The lid with a RigidJoint for the hinge] :end-before: [A screw to attach the hinge to the box] - :emphasize-lines: 6-10 + :emphasize-lines: 6-9 Again, the original orientation of the lid and hinge inner leaf are not important, when the joints are connected together the parts will move into the correct position. @@ -208,6 +186,7 @@ Step 6: Import a Screw and bind a Joint to it screw. .. image:: assets/tutorial_joint_m6_screw.svg + :align: center .. literalinclude:: tutorial_joints.py :start-after: [A screw to attach the hinge to the box] @@ -237,6 +216,7 @@ of ``hinge_outer``. Note that the hinge leaf is the object to move. Once this l is executed, we get the following: .. image:: assets/tutorial_joint_box_outer.svg + :align: center Step 7b: Hinge to Hinge ----------------------- @@ -253,6 +233,7 @@ parameter that can be set (angles default to the minimum range value) - here to This is what that looks like: .. image:: assets/tutorial_joint_box_outer_inner.svg + :align: center Step 7c: Lid to Hinge --------------------- @@ -266,6 +247,7 @@ Now the ``lid`` is connected to the ``hinge_inner``: which results in: .. image:: assets/tutorial_joint_box_outer_inner_lid.svg + :align: center Note how the lid is now in an open position. To close the lid just change the above ``angle`` parameter from 120° to 90°. @@ -283,6 +265,7 @@ As the position is a positive number the screw is still proud of the hinge face here: .. image:: assets/tutorial_joint.svg + :align: center Try changing these position and angle values to "tighten" the screw. @@ -310,3 +293,10 @@ and ``other`` will move to the appropriate :class:`~geometry.Location`. show_object(m6_joint.symbol, name="m6 screw symbol") + or, with the ocp_vscode viewer + + .. code:: python + + show(box, render_joints=True) + + diff --git a/docs/tutorial_lego.rst b/docs/tutorial_lego.rst index 9c546385..f079ec30 100644 --- a/docs/tutorial_lego.rst +++ b/docs/tutorial_lego.rst @@ -5,7 +5,7 @@ Lego Tutorial This tutorial provides a step by step guide to creating a script to build a parametric Lego block as shown here: -.. image:: tutorial_lego.svg +.. image:: assets/lego.svg :align: center ************* @@ -21,7 +21,7 @@ The dimensions of the Lego block follow. A key parameter is ``pip_count``, the l of the Lego blocks in pips. This parameter must be at least 2. .. literalinclude:: ../examples/lego.py - :lines: 29-44 + :lines: 29, 32-45 ******************** Step 2: Part Builder @@ -31,7 +31,7 @@ The Lego block will be created by the ``BuildPart`` builder as it's a discrete t dimensional part; therefore, we'll instantiate a ``BuildPart`` with the name ``lego``. .. literalinclude:: ../examples/lego.py - :lines: 52 + :lines: 47 ********************** Step 3: Sketch Builder @@ -43,7 +43,7 @@ object. As this sketch will be part of the lego part, we'll create a sketch bui in the context of the part builder as follows: .. literalinclude:: ../examples/lego.py - :lines: 52-54 + :lines: 47-49 :emphasize-lines: 3 @@ -59,12 +59,12 @@ of the Lego block. The following step is going to refer to this rectangle, so it be assigned the identifier ``perimeter``. .. literalinclude:: ../examples/lego.py - :lines: 52-56 + :lines: 47-51 :emphasize-lines: 5 Once the ``Rectangle`` object is created the sketch appears as follows: -.. image:: tutorial_step4.svg +.. image:: assets/lego_step4.svg :align: center ****************************** @@ -76,7 +76,7 @@ hollowed out. This will be done with the ``Offset`` operation which is going to create a new object from ``perimeter``. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66 + :lines: 47-51,55-61 :emphasize-lines: 7-12 The first parameter to ``Offset`` is the reference object. The ``amount`` is a @@ -86,7 +86,7 @@ square corners. Finally, the ``mode`` parameter controls how this object will be placed in the sketch - in this case subtracted from the existing sketch. The result is shown here: -.. image:: tutorial_step5.svg +.. image:: assets/lego_step5.svg :align: center Now the sketch consists of a hollow rectangle. @@ -104,7 +104,7 @@ objects are in the scope of a location context (``GridLocations`` in this case) that defined multiple points, multiple rectangles are created. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74 + :lines: 47-51,55-61,65-69 :emphasize-lines: 13-17 Here we can see that the first ``GridLocations`` creates two positions which causes @@ -114,7 +114,7 @@ parameter are optional in this case. The result looks like this: -.. image:: tutorial_step6.svg +.. image:: assets/lego_step6.svg :align: center ********************* @@ -125,12 +125,12 @@ To convert the internal grid to ridges, the center needs to be removed. This wil with another ``Rectangle``. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74,79-83 - :emphasize-lines: 18-22 + :lines: 47-51,55-61,65-69,74-78 + :emphasize-lines: 17-22 The ``Rectangle`` is subtracted from the sketch to leave the ridges as follows: -.. image:: tutorial_step7.svg +.. image:: assets/lego_step7.svg :align: center @@ -142,8 +142,8 @@ Lego blocks use a set of internal hollow cylinders that the pips push against to hold two blocks together. These will be created with ``Circle``. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74,79-83,87-92 - :emphasize-lines: 23-28 + :lines: 47-51,55-61,65-69,74-76,82-87 + :emphasize-lines: 21-26 Here another ``GridLocations`` is used to position the centers of the circles. Note that since both ``Circle`` objects are in the scope of the location context, both @@ -151,7 +151,7 @@ Circles will be positioned at these locations. Once the Circles are added, the sketch is complete and looks as follows: -.. image:: tutorial_step8.svg +.. image:: assets/lego_step8.svg :align: center *********************************** @@ -162,8 +162,8 @@ Now that the sketch is complete it needs to be extruded into the three dimension wall object. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74,79-83,87-92,96-97 - :emphasize-lines: 29-30 + :lines: 47-51,55-61,65-69,74-76,82-87,91-92 + :emphasize-lines: 27-28 Note how the ``Extrude`` operation is no longer in the ``BuildSketch`` scope and has returned back into the ``BuildPart`` scope. This causes ``BuildSketch`` to exit and transfer the @@ -171,7 +171,7 @@ sketch that we've created to ``BuildPart`` for further processing by ``Extrude`` The result is: -.. image:: tutorial_step9.svg +.. image:: assets/lego_step9.svg :align: center @@ -183,8 +183,8 @@ Now that the walls are complete, the top of the block needs to be added. Althoug could be done with another sketch, we'll add a box to the top of the walls. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74,79-83,87-92,96-97,101-109 - :emphasize-lines: 31-39 + :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108 + :emphasize-lines: 29-37 To position the top, we'll describe the top center of the lego walls with a ``Locations`` context. To determine the height we'll extract that from the @@ -200,7 +200,7 @@ the intersection of the x and y axis but not in the z thus aligning with the top The base is closed now as shown here: -.. image:: tutorial_step10.svg +.. image:: assets/lego_step10.svg :align: center ******************** @@ -211,8 +211,8 @@ The final step is to add the pips to the top of the Lego block. To do this we'll a new workplane on top of the block where we can position the pips. .. literalinclude:: ../examples/lego.py - :lines: 52-56,60-66,70-74,79-83,87-92,96-97,101-109,120-128 - :emphasize-lines: 40-48 + :lines: 47-51,55-61,65-69,74-76,82-87,91-92,100-108,116-124 + :emphasize-lines: 38-46 In this case, the workplane is created from the top Face of the Lego block by using the ``faces`` method and then sorted vertically and taking the top one ``sort_by(Axis.Z)[-1]``. @@ -220,7 +220,7 @@ In this case, the workplane is created from the top Face of the Lego block by us On the new workplane, a grid of locations is created and a number of ``Cylinder``'s are positioned at each location. -.. image:: tutorial_step11.svg +.. image:: assets/lego.svg :align: center This completes the Lego block. To access the finished product, refer to the builder's internal diff --git a/docs/tutorial_lego.svg b/docs/tutorial_lego.svg deleted file mode 100644 index b36f6ac2..00000000 --- a/docs/tutorial_lego.svg +++ /dev/null @@ -1,426 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_selectors.rst b/docs/tutorial_selectors.rst index 96a06635..82f8f0c2 100644 --- a/docs/tutorial_selectors.rst +++ b/docs/tutorial_selectors.rst @@ -5,7 +5,7 @@ Selector Tutorial This tutorial provides a step by step guide in using selectors as we create this part: -.. image:: selector_after.svg +.. image:: assets/selector_after.svg :align: center @@ -27,7 +27,7 @@ To start off, the part will be based on a cylinder so we'll use the ``Cylinder`` of ``BuildPart``: .. literalinclude:: selector_example.py - :lines: 27,31-33 + :lines: 27,30-32 :emphasize-lines: 3-4 @@ -41,7 +41,7 @@ surfaces) , so we'll create a sketch centered on the top of the cylinder. To lo this sketch we'll use the cylinder's top Face as shown here: .. literalinclude:: selector_example.py - :lines: 27,31-34 + :lines: 27,30-33 :emphasize-lines: 5 Here we're using selectors to find that top Face - let's break down @@ -71,7 +71,7 @@ The object has a hexagonal hole in the top with a central cylinder which we'll d in the sketch. .. literalinclude:: selector_example.py - :lines: 27,31-36 + :lines: 27,30-35 :emphasize-lines: 6-7 Step 4a: Draw a hexagon @@ -94,7 +94,7 @@ To create the hole we'll ``Extrude`` the sketch we just created into the ``Cylinder`` and subtract it. .. literalinclude:: selector_example.py - :lines: 27,31-37 + :lines: 27,30-36 :emphasize-lines: 8 Note that ``amount=-2`` indicates extruding into the part and - just like @@ -103,7 +103,7 @@ this hexagonal shape from the part under construction. At this point the part looks like: -.. image:: selector_before.svg +.. image:: assets/selector_before.svg :align: center ************************************* @@ -113,7 +113,7 @@ Step 6: Fillet the top perimeter Edge The final step is to apply a fillet to the top perimeter. .. literalinclude:: selector_example.py - :lines: 27,31-37,41-47 + :lines: 27,30-36,45-51 :emphasize-lines: 9-15 Here we're using the ``Fillet`` operation which needs two things: diff --git a/docs/tutorial_step10.svg b/docs/tutorial_step10.svg deleted file mode 100644 index 08c5bcf0..00000000 --- a/docs/tutorial_step10.svg +++ /dev/null @@ -1,316 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step11.svg b/docs/tutorial_step11.svg deleted file mode 100644 index 29937ffe..00000000 --- a/docs/tutorial_step11.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step4.svg b/docs/tutorial_step4.svg deleted file mode 100644 index 646c2845..00000000 --- a/docs/tutorial_step4.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step5.svg b/docs/tutorial_step5.svg deleted file mode 100644 index a6bc9a89..00000000 --- a/docs/tutorial_step5.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step6.svg b/docs/tutorial_step6.svg deleted file mode 100644 index d4d4ef90..00000000 --- a/docs/tutorial_step6.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step7.svg b/docs/tutorial_step7.svg deleted file mode 100644 index dfb73c43..00000000 --- a/docs/tutorial_step7.svg +++ /dev/null @@ -1,92 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step8.svg b/docs/tutorial_step8.svg deleted file mode 100644 index 7ba26f01..00000000 --- a/docs/tutorial_step8.svg +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/docs/tutorial_step9.svg b/docs/tutorial_step9.svg deleted file mode 100644 index 097bbeaa..00000000 --- a/docs/tutorial_step9.svg +++ /dev/null @@ -1,198 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/examples/benchy.py b/examples/benchy.py new file mode 100644 index 00000000..7a1c06a7 --- /dev/null +++ b/examples/benchy.py @@ -0,0 +1,75 @@ +""" +STL import and edit example + +name: benchy.py +by: Gumyr +date: July 9, 2023 + +desc: + This example imports a STL model as a Solid object and changes it. + The low-poly-benchy used in this example is by reddaugherty, see + https://www.printables.com/model/151134-low-poly-benchy. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from build123d import * +from ocp_vscode import * + +with BuildPart() as benchy: + # Import the benchy as a Solid model and add it + add(import_stl("low_poly_benchy.stl", for_reference=False)) + + # Determine the plane that defines the top of the roof + vertices = benchy.vertices() + roof_vertices = vertices.filter_by_position(Axis.Z, 38, 42) + roof_plane_vertices = [ + roof_vertices.group_by(Axis.Y, tol_digits=2)[-1].sort_by(Axis.X)[0], + roof_vertices.sort_by(Axis.Z)[0], + roof_vertices.group_by(Axis.Y, tol_digits=2)[0].sort_by(Axis.X)[0], + ] + roof_plane = Plane( + Face.make_from_wires( + Wire.make_polygon([v.to_tuple() for v in roof_plane_vertices]) + ) + ) + # Remove the faceted smoke stack + split(bisect_by=roof_plane, keep=Keep.BOTTOM) + + # Determine the position and size of the smoke stack + smoke_stack_vertices = vertices.group_by(Axis.Z, tol_digits=0)[-1] + smoke_stack_center = sum( + [Vector(v.X, v.Y, v.Z) for v in smoke_stack_vertices], Vector() + ) * (1 / len(smoke_stack_vertices)) + smoke_stack_radius = max( + [ + (Vector(*v.to_tuple()) - smoke_stack_center).length + for v in smoke_stack_vertices + ] + ) + + # Create the new smoke stack + with BuildSketch(Plane(smoke_stack_center)): + Circle(smoke_stack_radius) + Circle(smoke_stack_radius - 2 * MM, mode=Mode.SUBTRACT) + extrude(amount=-3 * MM) + with BuildSketch(Plane(smoke_stack_center)): + Circle(smoke_stack_radius - 0.5 * MM) + Circle(smoke_stack_radius - 2 * MM, mode=Mode.SUBTRACT) + extrude(amount=roof_plane_vertices[1].Z - smoke_stack_center.Z) + +show(benchy) diff --git a/examples/build123d_customizable_logo.py b/examples/build123d_customizable_logo.py index 720b4bf2..db053cdc 100644 --- a/examples/build123d_customizable_logo.py +++ b/examples/build123d_customizable_logo.py @@ -25,6 +25,7 @@ limitations under the License. """ from build123d import * +from ocp_vscode import * with BuildSketch() as logo_text: Text("123d", font_size=10, align=(Align.MIN, Align.MIN)) @@ -43,9 +44,8 @@ align=(Align.CENTER, Align.CENTER), font_style=FontStyle.BOLD, ) - cust_bb = bounding_box(cust_text.sketch, mode=Mode.PRIVATE) - cust_vertices = cust_text.vertices().sort_by(Axis.X) - cust_width = cust_vertices[-1].X - cust_vertices[0].X + cust_bb = cust_text.sketch.bounding_box() + cust_width = cust_bb.size.X with BuildLine() as one: l1 = Line((font_height * 0.3, 0), (font_height * 0.3, font_height)) @@ -56,11 +56,10 @@ Text("2", font_size=10, align=(Align.MIN, Align.MIN)) with BuildPart() as three_d: - with Locations((font_height * 1.1, 0)): - with BuildSketch(): - Text("3d", font_size=10, align=(Align.MIN, Align.MIN)) - extrude(amount=font_height * 0.3) - logo_width = three_d.vertices().sort_by(Axis.X)[-1].X + with BuildSketch(Plane((font_height * 1.1, 0))): + Text("3d", font_size=10, align=(Align.MIN, Align.MIN)) + extrude(amount=font_height * 0.3) + logo_width = three_d.vertices().sort_by(Axis.X)[-1].X with BuildLine() as arrow_left: t1 = TangentArc((0, 0), (1, 0.75), tangent=(1, 0)) @@ -88,31 +87,22 @@ - Vector((build_vertices[-1].X + build_vertices[0].X) / 2, 0) ): add(build_text.sketch) - # add the customizable text to the build text sketch - with Locations( - (l1 @ 1 + l2 @ 1) / 2 - Vector((cust_vertices[-1].X + cust_vertices[0].X), 1.4) - ): + with Locations((logo_width / 2, -6)): add(cust_text.sketch) cmpd = Compound.make_compound( [three_d.part, two.sketch, one.line, build.sketch, extension_lines.line] ) -cmpd.export_svg( - "cmpd.svg", - (-10, 10, 60), - (0, 0, 1), - svg_opts={ - "pixel_scale": 20, - "show_axes": False, - "show_hidden": False, - }, -) - -if "show_object" in locals(): - show_object(cmpd, name="compound") - # show_object(one.line.wrapped, name="one") - # show_object(two.sketch.wrapped, name="two") - # show_object(three_d.part.wrapped, name="three_d") - # show_object(extension_lines.line.wrapped, name="extension_lines") - # show_object(build.sketch.wrapped, name="build") +visible, _hidden = cmpd.project_to_viewport((10, -10, 60)) +max_dimension = max(*Compound(children=visible).bounding_box().size) +exporter = ExportSVG(scale=100 / max_dimension) +exporter.add_shape(visible) +exporter.write(f"cmpd.svg") + +show_object(cmpd, name="compound") +# show_object(one.line.wrapped, name="one") +# show_object(two.sketch.wrapped, name="two") +# show_object(three_d.part.wrapped, name="three_d") +# show_object(extension_lines.line.wrapped, name="extension_lines") +# show_object(build.sketch.wrapped, name="build") diff --git a/examples/build123d_logo.py b/examples/build123d_logo.py index bcc382c5..ec6d2534 100644 --- a/examples/build123d_logo.py +++ b/examples/build123d_logo.py @@ -25,6 +25,8 @@ limitations under the License. """ from build123d import * +from build123d import Shape +from ocp_vscode import * with BuildSketch() as logo_text: Text("123d", font_size=10, align=(Align.MIN, Align.MIN)) @@ -45,11 +47,10 @@ Text("2", font_size=10, align=(Align.MIN, Align.MIN)) with BuildPart() as three_d: - with Locations((font_height * 1.1, 0)): - with BuildSketch(): - Text("3d", font_size=10, align=(Align.MIN, Align.MIN)) - extrude(amount=font_height * 0.3) - logo_width = three_d.vertices().sort_by(Axis.X)[-1].X + with BuildSketch(Plane((font_height * 1.1, 0))): + Text("3d", font_size=10, align=(Align.MIN, Align.MIN)) + extrude(amount=font_height * 0.3) + logo_width = three_d.vertices().sort_by(Axis.X)[-1].X with BuildLine() as arrow_left: t1 = TangentArc((0, 0), (1, 0.75), tangent=(1, 0)) @@ -78,28 +79,44 @@ ): add(build_text.sketch) -if False: - logo.save("logo.step") - exporters.export( - logo.toCompound(), - "logo.svg", - opt={ - # "width": 300, - # "height": 300, - # "marginLeft": 10, - # "marginTop": 10, - "showAxes": False, - # "projectionDir": (0.5, 0.5, 0.5), - "strokeWidth": 0.1, - # "strokeColor": (255, 0, 0), - # "hiddenColor": (0, 0, 255), - "showHidden": False, - }, + +if True: + logo = Compound( + children=[ + one.line, + two.sketch, + three_d.part, + extension_lines.line, + build.sketch, + ] ) -if "show_object" in locals(): - show_object(one.line.wrapped, name="one") - show_object(two.sketch.wrapped, name="two") - show_object(three_d.part.wrapped, name="three_d") - show_object(extension_lines.line.wrapped, name="extension_lines") - show_object(build.sketch.wrapped, name="build") + # logo.export_step("logo.step") + def add_svg_shape(svg: ExportSVG, shape: Shape, color: tuple[float, float, float]): + global counter + try: + counter += 1 + except: + counter = 1 + + visible, _hidden = shape.project_to_viewport( + (-5, 1, 10), viewport_up=(0, 1, 0), look_at=(0, 0, 0) + ) + if color is not None: + svg.add_layer(str(counter), fill_color=color, line_weight=1) + else: + svg.add_layer(str(counter), line_weight=1) + svg.add_shape(visible, layer=str(counter)) + + svg = ExportSVG(scale=20) + add_svg_shape(svg, logo, None) + # add_svg_shape(svg, Compound(children=[one.line, extension_lines.line]), None) + # add_svg_shape(svg, Compound(children=[two.sketch, build.sketch]), (170, 204, 255)) + # add_svg_shape(svg, three_d.part, (85, 153, 255)) + svg.write("logo.svg") + +show_object(one, name="one") +show_object(two, name="two") +show_object(three_d, name="three_d") +show_object(extension_lines, name="extension_lines") +show_object(build, name="build") diff --git a/examples/canadian_flag.py b/examples/canadian_flag.py index 57dcf037..827ffa3f 100644 --- a/examples/canadian_flag.py +++ b/examples/canadian_flag.py @@ -31,6 +31,7 @@ """ from math import sin, cos, pi from build123d import * +from ocp_vscode import show_object # Canadian Flags have a 2:1 aspect ratio height = 50 @@ -67,7 +68,9 @@ def surface(amplitude, u, v): with BuildSketch(Plane((width / 2, 0, 10))) as center_field_builder: Rectangle(width / 2, height, align=(Align.CENTER, Align.MIN)) - with BuildSketch(mode=Mode.SUBTRACT) as maple_leaf_builder: + with BuildSketch( + Plane((width / 2, 0, 10)), mode=Mode.SUBTRACT + ) as maple_leaf_builder: with BuildLine() as outline: l1 = Polyline((0.0000, 0.0771), (0.0187, 0.0771), (0.0094, 0.2569)) l2 = Polyline((0.0325, 0.2773), (0.2115, 0.2458), (0.1873, 0.3125)) @@ -100,13 +103,12 @@ def surface(amplitude, u, v): center_field = center_field_planar.project_to_shape(the_wind, (0, 0, -1))[0] maple_leaf = maple_leaf_planar.project_to_shape(the_wind, (0, 0, -1))[0] -if "show_object" in locals(): - # show_object( - # the_wind, - # name="the_wind", - # options={"alpha": 0.8, "color": (170 / 255, 85 / 255, 255 / 255)}, - # ) - show_object(west_field, name="west", options={"color": (255, 0, 0)}) - show_object(east_field, name="east", options={"color": (255, 0, 0)}) - show_object(center_field, name="center", options={"color": (255, 255, 255)}) - show_object(maple_leaf, name="maple", options={"color": (255, 0, 0)}) +# show_object( +# the_wind, +# name="the_wind", +# options={"alpha": 0.8, "color": (170 / 255, 85 / 255, 255 / 255)}, +# ) +show_object(west_field, name="west", options={"color": (255, 0, 0)}) +show_object(east_field, name="east", options={"color": (255, 0, 0)}) +show_object(center_field, name="center", options={"color": (255, 255, 255)}) +show_object(maple_leaf, name="maple", options={"color": (255, 0, 0)}) diff --git a/examples/dual_color_3mf.py b/examples/dual_color_3mf.py new file mode 100644 index 00000000..b2c35d47 --- /dev/null +++ b/examples/dual_color_3mf.py @@ -0,0 +1,68 @@ +""" + +Dual Color Export to 3MF Format + +name: dual_color_3mf.py +by: Gumyr +date: August 13th 2023 + +desc: The 3MF mesh format supports multiple colors which can be used on + multi-filament 3D printers. This example creates an tile pattern + with an insert and background in different colors. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from build123d import * +from ocp_vscode import * + + +# Create a simple tile pattern +with BuildSketch() as inset_pattern: + with BuildLine() as bl: + Polyline((9, 9), (1, 5), (-0.5, 0)) + offset(amount=1, side=Side.LEFT) + make_face() + split(bisect_by=Plane(origin=(0, 0, 0), z_dir=(-1, 1, 0))) + mirror(about=Plane(origin=(0, 0, 0), z_dir=(-1, 1, 0))) + mirror(about=Plane.YZ) + mirror(about=Plane.XZ) + +# Create the background field object for the tile +with BuildPart() as outset_builder: + with BuildSketch(): + Rectangle(20, 20) + add(inset_pattern.sketch, mode=Mode.SUBTRACT) + extrude(amount=1) + +# Create the inset object for the tile +with BuildPart() as inset_builder: + add(inset_pattern.sketch) + extrude(amount=1) + +# Assign colors to the tile parts +outset = outset_builder.part +outset.color = Color(0.137, 0.306, 0.439) # Tealish +inset = inset_builder.part +inset.color = Color(0.980, 0.973, 0.749) # Goldish + +show(inset, outset) + +# Export the tile with the units as CM +exporter = Mesher(unit=Unit.CM) +exporter.add_shape([inset, outset]) +exporter.write("dual_color.3mf") diff --git a/examples/joints.py b/examples/joints.py index e1b6edb7..f143af5e 100644 --- a/examples/joints.py +++ b/examples/joints.py @@ -2,6 +2,7 @@ Experimental Joint development file """ from build123d import * +from ocp_vscode import * class JointBox(Solid): @@ -53,9 +54,9 @@ def __init__( # Rigid Joint # fixed_arm = JointBox(1, 1, 5, 0.2) -j1 = RigidJoint("side", base, Plane(base.faces().sort_by(Axis.X)[-1]).to_location()) +j1 = RigidJoint("side", base, Plane(base.faces().sort_by(Axis.X)[-1]).location) j2 = RigidJoint( - "top", fixed_arm, (-Plane(fixed_arm.faces().sort_by(Axis.Z)[-1])).to_location() + "top", fixed_arm, (-Plane(fixed_arm.faces().sort_by(Axis.Z)[-1])).location ) base.joints["side"].connect_to(fixed_arm.joints["top"]) # or @@ -75,7 +76,7 @@ def __init__( base_corner_edge = base.edges().sort_by(Axis((0, 0, 0), (1, 1, 0)))[-1] base_hinge_axis = base_corner_edge.to_axis() j3 = RevoluteJoint("hinge", base, axis=base_hinge_axis, angular_range=(0, 180)) -j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.to_location()) +j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.location) base.joints["hinge"].connect_to(hinge_arm.joints["corner"], angle=90) # @@ -120,26 +121,9 @@ def __init__( # # BallJoint # -j9 = BallJoint("socket", base, Plane(base.faces().sort_by(Axis.X)[0]).to_location()) +j9 = BallJoint("socket", base, Plane(base.faces().sort_by(Axis.X)[0]).location) ball = JointBox(2, 2, 2, 0.99) j10 = RigidJoint("ball", ball, Location(Vector(0, 0, 1))) j9.connect_to(j10, angles=(10, 20, 30)) -if "show_object" in locals(): - show_object(base, name="base", options={"alpha": 0.8}) - show_object(base.joints["side"].symbol, name="side joint") - show_object(base.joints["hinge"].symbol, name="hinge joint") - show_object(base.joints["slide"].symbol, name="slot joint") - show_object(base.joints["slot"].symbol, name="pin slot joint") - show_object(base.joints["hole"].symbol, name="hole") - show_object(base.joints["socket"].symbol, name="socket joint") - show_object(hinge_arm.joints["corner"].symbol, name="hinge_arm joint") - show_object(fixed_arm, name="fixed_arm", options={"alpha": 0.6}) - show_object(fixed_arm.joints["top"].symbol, name="fixed_arm joint") - show_object(hinge_arm, name="hinge_arm", options={"alpha": 0.6}) - show_object(slider_arm, name="slider_arm", options={"alpha": 0.6}) - show_object(pin_arm, name="pin_arm", options={"alpha": 0.6}) - show_object(slider_arm.joints["slide"].symbol, name="slider attachment") - show_object(pin_arm.joints["pin"].symbol, name="pin axis") - show_object(screw_arm, name="screw_arm") - show_object(ball, name="ball", options={"alpha": 0.6}) +show_all(render_joints=True, transparent=True) diff --git a/examples/joints_algebra.py b/examples/joints_algebra.py index 42ac13cc..c0da3947 100644 --- a/examples/joints_algebra.py +++ b/examples/joints_algebra.py @@ -1,4 +1,5 @@ from build123d import * +from ocp_vscode import * class JointBox(Part): @@ -45,12 +46,8 @@ def __init__( # Rigid Joint # fixed_arm = JointBox(1, 1, 5, 0.2) -j1 = RigidJoint( - "side", base, Plane(base.faces().sort_by(loc.x_axis).last).to_location() -) -j2 = RigidJoint( - "top", fixed_arm, (-Plane(fixed_arm.faces().sort_by().last)).to_location() -) +j1 = RigidJoint("side", base, Plane(base.faces().sort_by(loc.x_axis).last).location) +j2 = RigidJoint("top", fixed_arm, (-Plane(fixed_arm.faces().sort_by().last)).location) base.joints["side"].connect_to(fixed_arm.joints["top"]) # or # j1.connect_to(j2) @@ -69,7 +66,7 @@ def __init__( base_corner_edge = base.edges().sort_by(Axis((0, 0, 0), (1, 1, 0)))[-1] base_hinge_axis = base_corner_edge.to_axis() j3 = RevoluteJoint("hinge", base, axis=base_hinge_axis, angular_range=(0, 180)) -j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.to_location()) +j4 = RigidJoint("corner", hinge_arm, swing_arm_hinge_axis.location) base.joints["hinge"].connect_to(hinge_arm.joints["corner"], angle=90) @@ -115,26 +112,9 @@ def __init__( # # BallJoint # -j9 = BallJoint("socket", base, Plane(base.faces().sort_by(Axis.X)[0]).to_location()) +j9 = BallJoint("socket", base, Plane(base.faces().sort_by(Axis.X)[0]).location) ball = JointBox(2, 2, 2, 0.99) j10 = RigidJoint("ball", ball, Location(Vector(0, 0, 1))) j9.connect_to(j10, angles=(10, 20, 30)) -if "show_object" in locals(): - show_object(base, name="base", options={"alpha": 0.8}) - show_object(base.joints["side"].symbol, name="side joint") - show_object(base.joints["hinge"].symbol, name="hinge joint") - show_object(base.joints["slide"].symbol, name="slot joint") - show_object(base.joints["slot"].symbol, name="pin slot joint") - show_object(base.joints["hole"].symbol, name="hole") - show_object(base.joints["socket"].symbol, name="socket joint") - show_object(hinge_arm.joints["corner"].symbol, name="hinge_arm joint") - show_object(fixed_arm, name="fixed_arm", options={"alpha": 0.6}) - show_object(fixed_arm.joints["top"].symbol, name="fixed_arm joint") - show_object(hinge_arm, name="hinge_arm", options={"alpha": 0.6}) - show_object(slider_arm, name="slider_arm", options={"alpha": 0.6}) - show_object(pin_arm, name="pin_arm", options={"alpha": 0.6}) - show_object(slider_arm.joints["slide"].symbol, name="slider attachment") - show_object(pin_arm.joints["pin"].symbol, name="pin axis") - show_object(screw_arm, name="screw_arm") - show_object(ball, name="ball", options={"alpha": 0.6}) +show_all(render_joints=True, transparent=True) diff --git a/examples/key_cap.py b/examples/key_cap.py index a59b348f..aebbae99 100644 --- a/examples/key_cap.py +++ b/examples/key_cap.py @@ -28,6 +28,7 @@ limitations under the License. """ from build123d import * +from ocp_vscode import * with BuildPart() as key_cap: # Start with the plan of the key cap and extrude it @@ -63,5 +64,4 @@ assert abs(key_cap.part.volume - 644.8900473617498) < 1e-3 -if "show_object" in locals(): - show_object(key_cap.part.wrapped, name="key cap", options={"alpha": 0.7}) +show(key_cap, alphas=[0.3]) diff --git a/examples/key_cap_algebra.py b/examples/key_cap_algebra.py index fdfc01ea..c2d9208b 100644 --- a/examples/key_cap_algebra.py +++ b/examples/key_cap_algebra.py @@ -1,4 +1,5 @@ from build123d import * +from ocp_vscode import * # Taper Extrude and Extrude to "next" while creating a Cherry MX key cap # See: https://www.cherrymx.de/en/dev.html @@ -37,5 +38,4 @@ socket -= Rectangle(1.17 * MM, 4.1 * MM) key_cap += extrude(Plane(rib_bottom) * socket, amount=3.5 * MM) -if "show_object" in locals(): - show_object(key_cap, name="key cap", options={"alpha": 0.7}) +show(key_cap, alphas=[0.3]) diff --git a/examples/lego.py b/examples/lego.py index 91ab9613..cbd7cadd 100644 --- a/examples/lego.py +++ b/examples/lego.py @@ -27,6 +27,7 @@ limitations under the License. """ from build123d import * +from ocp_vscode import * pip_count = 6 @@ -43,20 +44,14 @@ ridge_depth = 0.3 wall_thickness = 1.2 -svg_opts = { - "pixel_scale": 20, - "show_axes": False, - "show_hidden": False, -} - with BuildPart() as lego: # Draw the bottom of the block with BuildSketch() as plan: # Start with a Rectangle the size of the block perimeter = Rectangle(width=block_length, height=block_width) - plan.sketch.export_svg( - "tutorial_step4.svg", (0, 0, 10), (0, 1, 0), svg_opts=svg_opts - ) + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step4.svg") # Subtract an offset to create the block walls offset( perimeter, @@ -64,40 +59,44 @@ kind=Kind.INTERSECTION, mode=Mode.SUBTRACT, ) - plan.sketch.export_svg( - "tutorial_step5.svg", (0, 0, 10), (0, 1, 0), svg_opts=svg_opts - ) + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step5.svg") # Add a grid of lengthwise and widthwise bars with GridLocations(x_spacing=0, y_spacing=lego_unit_size, x_count=1, y_count=2): Rectangle(width=block_length, height=ridge_width) with GridLocations(lego_unit_size, 0, pip_count, 1): Rectangle(width=ridge_width, height=block_width) - plan.sketch.export_svg( - "tutorial_step6.svg", (0, 0, 10), (0, 1, 0), svg_opts=svg_opts - ) + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step6.svg") # Substract a rectangle leaving ribs on the block walls Rectangle( block_length - 2 * (wall_thickness + ridge_depth), block_width - 2 * (wall_thickness + ridge_depth), mode=Mode.SUBTRACT, ) - plan.sketch.export_svg( - "tutorial_step7.svg", (0, 0, 10), (0, 1, 0), svg_opts=svg_opts - ) + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step7.svg") # Add a row of hollow circles to the center with GridLocations( x_spacing=lego_unit_size, y_spacing=0, x_count=pip_count - 1, y_count=1 ): Circle(radius=support_outer_diameter / 2) Circle(radius=support_inner_diameter / 2, mode=Mode.SUBTRACT) - plan.sketch.export_svg( - "tutorial_step8.svg", (0, 0, 10), (0, 1, 0), svg_opts=svg_opts - ) + exporter = ExportSVG(scale=6) + exporter.add_shape(plan.sketch) + exporter.write("assets/lego_step8.svg") # Extrude this base sketch to the height of the walls extrude(amount=base_height - wall_thickness) - lego.part.export_svg( - "tutorial_step9.svg", (-5, -30, 50), (0, 0, 1), svg_opts=svg_opts - ) + visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego_step9.svg") # Create a box on the top of the walls with Locations((0, 0, lego.vertices().sort_by(Axis.Z)[-1].Z)): # Create the top of the block @@ -107,16 +106,13 @@ height=wall_thickness, align=(Align.CENTER, Align.CENTER, Align.MIN), ) - lego.part.export_svg( - "tutorial_step10.svg", - (-5, -30, 50), - (0, 0, 1), - svg_opts={ - "pixel_scale": 20, - "show_axes": False, - "show_hidden": True, - }, - ) + visible, hidden = lego.part.project_to_viewport((-5, -30, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego_step10.svg") # Create a workplane on the top of the block with BuildPart(lego.faces().sort_by(Axis.Z)[-1]): # Create a grid of pips @@ -126,21 +122,14 @@ height=pip_height, align=(Align.CENTER, Align.CENTER, Align.MIN), ) - lego.part.export_svg( - "tutorial_step11.svg", (-100, -100, 50), (0, 0, 1), svg_opts=svg_opts - ) - lego.part.export_svg( - "tutorial_lego.svg", - (-100, -100, 50), - (0, 0, 1), - svg_opts={ - "pixel_scale": 20, - "show_axes": False, - "show_hidden": True, - }, - ) + visible, hidden = lego.part.project_to_viewport((-100, -100, 50)) + exporter = ExportSVG(scale=6) + exporter.add_layer("Visible") + exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) + exporter.add_shape(visible, layer="Visible") + exporter.add_shape(hidden, layer="Hidden") + exporter.write("assets/lego.svg") assert abs(lego.part.volume - 3212.187337781355) < 1e-3 -if "show_object" in locals(): - show_object(lego.part.wrapped, name="lego") +show_object(lego.part.wrapped, name="lego") diff --git a/examples/low_poly_benchy.stl b/examples/low_poly_benchy.stl new file mode 100644 index 00000000..c048f1e3 Binary files /dev/null and b/examples/low_poly_benchy.stl differ diff --git a/examples/playing_cards.py b/examples/playing_cards.py index 3366c162..bcfe181a 100644 --- a/examples/playing_cards.py +++ b/examples/playing_cards.py @@ -28,6 +28,7 @@ from build123d import * +# [Club] class Club(BaseSketchObject): def __init__( self, @@ -36,7 +37,7 @@ def __init__( align: tuple[Align, Align] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - with BuildSketch(mode=Mode.PRIVATE) as club: + with BuildSketch() as club: with BuildLine(): l0 = Line((0, -188), (76, -188)) b0 = Bezier(l0 @ 1, (61, -185), (33, -173), (17, -81)) @@ -49,6 +50,9 @@ def __init__( super().__init__(obj=club.sketch, rotation=rotation, align=align, mode=mode) +# [Club] + + class Spade(BaseSketchObject): def __init__( self, @@ -57,7 +61,7 @@ def __init__( align: tuple[Align, Align] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - with BuildSketch(mode=Mode.PRIVATE) as spade: + with BuildSketch() as spade: with BuildLine(): b0 = Bezier((0, 198), (6, 190), (41, 127), (112, 61)) b1 = Bezier(b0 @ 1, (242, -72), (114, -168), (11, -105)) @@ -77,7 +81,7 @@ def __init__( align: tuple[Align, Align] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - with BuildSketch(mode=Mode.PRIVATE) as heart: + with BuildSketch() as heart: with BuildLine(): b1 = Bezier((0, 146), (20, 169), (67, 198), (97, 198)) b2 = Bezier(b1 @ 1, (125, 198), (151, 186), (168, 167)) @@ -98,7 +102,7 @@ def __init__( align: tuple[Align, Align] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - with BuildSketch(mode=Mode.PRIVATE) as diamond: + with BuildSketch() as diamond: with BuildLine(): Bezier((135, 0), (94, 69), (47, 134), (0, 198)) mirror(about=Plane.XZ) @@ -152,6 +156,18 @@ def __init__( Club(card_length / 5) extrude(amount=-wall, mode=Mode.SUBTRACT) +box = Compound.make_compound( + [box_builder.part, lid_builder.part.moved(Location((0, 0, (wall + deck) / 2)))] +) +visible, hidden = box.project_to_viewport((70, -50, 120)) +max_dimension = max(*Compound(children=visible + hidden).bounding_box().size) +exporter = ExportSVG(scale=100 / max_dimension) +exporter.add_layer("Visible") +exporter.add_layer("Hidden", line_color=(99, 99, 99), line_type=LineType.ISO_DOT) +exporter.add_shape(visible, layer="Visible") +exporter.add_shape(hidden, layer="Hidden") +exporter.write(f"assets/card_box.svg") + class PlayingCard(Compound): """PlayingCard diff --git a/examples/ttt_sm_hanger.py b/examples/ttt_sm_hanger.py new file mode 100644 index 00000000..c79cf1f2 --- /dev/null +++ b/examples/ttt_sm_hanger.py @@ -0,0 +1,97 @@ +""" +Creation of a complex sheet metal part + +name: ttt_sm_hanger.py +by: Gumyr +date: July 17, 2023 + +desc: + This example implements the sheet metal part described in Too Tall Toby's + sm_hanger CAD challenge. + + Notably, a BuildLine/Curve object is filleted by providing all the vertices + and allowing the fillet operation filter out the end vertices. The + make_brake_formed operation is used both in Algebra and Builder mode to + create a sheet metal part from just an outline and some dimensions. + license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from build123d import * +from ocp_vscode import * + +sheet_thickness = 4 * MM + +# Create the main body from a side profile +with BuildPart() as side: + d = Vector(1, 0, 0).rotate(Axis.Y, 60) + with BuildLine(Plane.XZ) as side_line: + l1 = Line((0, 65), (170 / 2, 65)) + l2 = PolarLine(l1 @ 1, length=65, direction=d, length_mode=LengthMode.VERTICAL) + l3 = Line(l2 @ 1, (170 / 2, 0)) + fillet(side_line.vertices(), 7) + make_brake_formed( + thickness=sheet_thickness, + station_widths=[40, 40, 40, 112.52 / 2, 112.52 / 2, 112.52 / 2], + side=Side.RIGHT, + ) + fe = side.edges().filter_by(Axis.Z).group_by(Axis.Z)[0].sort_by(Axis.Y)[-1] + fillet(fe, radius=7) + +# Create the "wings" at the top +with BuildPart() as wing: + with BuildLine(Plane.YZ) as wing_line: + l1 = Line((0, 65), (80 / 2 + 1.526 * sheet_thickness, 65)) + PolarLine(l1 @ 1, 20.371288916, direction=Vector(0, 1, 0).rotate(Axis.X, -75)) + fillet(wing_line.vertices(), 7) + make_brake_formed( + thickness=sheet_thickness, + station_widths=110 / 2, + side=Side.RIGHT, + ) + bottom_edge = wing.edges().group_by(Axis.X)[-1].sort_by(Axis.Z)[0] + fillet(bottom_edge, radius=7) + +# Create the tab at the top in Algebra mode +tab_line = Plane.XZ * Polyline( + (20, 65 - sheet_thickness), (56 / 2, 65 - sheet_thickness), (56 / 2, 88) +) +tab_line = fillet(tab_line.vertices(), 7) +tab = make_brake_formed(sheet_thickness, 8, tab_line, Side.RIGHT) +tab = fillet(tab.edges().filter_by(Axis.X).group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1], 5) +tab -= Pos((0, 0, 80)) * Rot(0, 90, 0) * Hole(5, 100) + +# Combine the parts together +with BuildPart() as sm_hanger: + add([side.part, wing.part]) + mirror(about=Plane.XZ) + with BuildSketch(Plane.XY.offset(65)) as h1: + with Locations((20, 0)): + Rectangle(30, 30, align=(Align.MIN, Align.CENTER)) + fillet(h1.vertices().group_by(Axis.X)[-1], 7) + SlotCenterPoint((154, 0), (154 / 2, 0), 20) + extrude(amount=-40, mode=Mode.SUBTRACT) + with BuildSketch() as h2: + SlotCenterPoint((206, 0), (206 / 2, 0), 20) + extrude(amount=40, mode=Mode.SUBTRACT) + add(tab) + mirror(about=Plane.YZ) + mirror(about=Plane.XZ) + +# Target 1028g +/- 10g +print(f"Mass: {sm_hanger.part.volume*7800*1e-6:0.1f} g") # 1027.7g + +show(sm_hanger) diff --git a/examples/vase.py b/examples/vase.py index 05d890c8..0de698be 100644 --- a/examples/vase.py +++ b/examples/vase.py @@ -26,6 +26,10 @@ limitations under the License. """ from build123d import * +from ocp_vscode import show, show_object, set_port, set_defaults + +set_port(3939) +set_defaults(reset_camera=True, ortho=True) with BuildPart() as vase: with BuildSketch() as profile: @@ -56,7 +60,6 @@ fillet(vase.edges().sort_by(Axis.Y)[0], radius=0.5) -if "show_object" in locals(): - # show_object(outline.line.wrapped, name="outline") - # show_object(profile.sketch.wrapped, name="profile") - show_object(vase.part.wrapped, name="vase") +# show_object(outline, name="outline") +# show_object(profile, name="profile") +show_object(vase, name="vase") diff --git a/pyproject.toml b/pyproject.toml index 737f5495..b716c70f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ build-backend = "setuptools.build_meta" [project] name = "build123d" -#version = "0.1.0" # Uncomment this for the next release? +#version = "0.1.0" +#Uncomment this ^ for the next release dynamic = ["version"] authors = [ {name = "Roger Maitland", email = "gumyr9@gmail.com"}, @@ -34,17 +35,25 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", ] - +d dependencies = [ - "cadquery-ocp ~= 7.7.0a0", + "cadquery-ocp ~= 7.7.1", "OCP-stubs @ git+https://github.com/CadQuery/OCP-stubs@7.7.0", "typing_extensions >= 4.4.0, <5", "numpy >= 1.24.1, <2", "svgpathtools >= 1.5.1, <2", "anytree >= 2.8.0, <3", - "ezdxf >= 1.0.0, < 2" + "ezdxf >= 1.0.0, < 2", + "numpy-stl >= 3.0.0, <4", + "ipython >= 8.0.0, <9", + "py_lib3mf @ git+https://github.com/jdegenstein/py-lib3mf", ] +[project.urls] +"Homepage" = "https://github.com/gumyr/build123d" +"Documentation" = "https://build123d.readthedocs.io/en/latest/index.html" +"Bug Tracker" = "https://github.com/gumyr/build123d/issues" + [tool.setuptools.packages.find] where = ["src"] # exclude build123d._dev from wheels diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index c4a2704c..93731961 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -1,27 +1,36 @@ """build123d import definitions""" from build123d.build_common import * +from build123d.build_enums import * from build123d.build_line import * -from build123d.build_sketch import * from build123d.build_part import * +from build123d.build_sketch import * +from build123d.exporters import * from build123d.geometry import * -from build123d.topology import * -from build123d.build_enums import * from build123d.importers import * +from build123d.joints import * +from build123d.mesher import * +from build123d.objects_curve import * +from build123d.objects_part import * +from build123d.objects_sketch import * from build123d.operations_generic import * from build123d.operations_part import * from build123d.operations_sketch import * -from build123d.objects_part import * -from build123d.objects_sketch import * -from build123d.objects_curve import * +from build123d.topology import * +from build123d.drafting import * + from .version import version as __version__ __all__ = [ - # Measurement Units + # Length Constants "MM", "CM", "M", "IN", "FT", + # Mass Constants + "G", + "KG", + "LB", # Enums "Align", "ApproxOption", @@ -30,12 +39,17 @@ "FontStyle", "FrameMethod", "GeomType", + "HeadType", "Keep", "Kind", "LengthMode", + "MeshType", "Mode", + "NumberDisplay", + "PageSize", "PositionMode", "Select", + "Side", "SortBy", "Transition", "Unit", @@ -44,17 +58,19 @@ "HexLocations", "PolarLocations", "Locations", - "Workplanes", "GridLocations", "BuildLine", "BuildPart", "BuildSketch", # 1D Curve Objects + "BaseLineObject", "Bezier", "CenterArc", "EllipticalCenterArc", "EllipticalStartArc", + "FilletPolyline", "Helix", + "IntersectingLine", "Line", "PolarLine", "Polyline", @@ -65,9 +81,14 @@ "JernArc", "ThreePointArc", # 2D Sketch Objects + "ArrowHead", + "Arrow", "BaseSketchObject", "Circle", + "Draft", + "DimensionLine", "Ellipse", + "ExtensionLine", "Polygon", "Rectangle", "RectangleRounded", @@ -77,6 +98,7 @@ "SlotCenterToCenter", "SlotOverall", "Text", + "TechnicalDrawing", "Trapezoid", # 3D Part Objects "BasePartObject", @@ -96,7 +118,6 @@ "Pos", "RotationLike", "ShapeList", - "SVG", "Axis", "Color", "Curve", @@ -120,6 +141,13 @@ "LinearJoint", "CylindricalJoint", "BallJoint", + # Exporter classes + "Export2D", + "ExportDXF", + "ExportSVG", + "LineType", + "DotLength", + "Mesher", # Importer functions "import_brep", "import_step", @@ -129,6 +157,8 @@ # Other functions "polar", "delta", + "new_edges", + "edges_to_wires", # Operations "add", "bounding_box", @@ -136,13 +166,19 @@ "extrude", "fillet", "loft", + "make_brake_formed", "make_face", "make_hull", "mirror", "offset", + "project", + # "project_points", + "project_workplane", "revolve", "scale", "section", "split", "sweep", + "thicken", + "trace", ] diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index cbbe9043..97641352 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -44,10 +44,11 @@ import inspect import logging import sys +import warnings from abc import ABC, abstractmethod from itertools import product from math import sqrt -from typing import Iterable, Union +from typing import Callable, Iterable, Union from typing_extensions import Self from build123d.build_enums import Align, Mode, Select @@ -65,6 +66,7 @@ Vertex, Wire, tuplify, + new_edges, ) # Create a build123d logger to distinguish these logs from application logs. @@ -84,6 +86,8 @@ # # CONSTANTS # + +# LENGTH CONSTANTS MM = 1 CM = 10 * MM M = 1000 * MM @@ -91,23 +95,31 @@ FT = 12 * IN THOU = IN / 1000 +# MASS CONSTANTS +G = 1 +KG = 1000 * G +LB = 453.59237 * G operations_apply_to = { "add": ["BuildPart", "BuildSketch", "BuildLine"], "bounding_box": ["BuildPart", "BuildSketch", "BuildLine"], "chamfer": ["BuildPart", "BuildSketch"], "extrude": ["BuildPart"], - "fillet": ["BuildPart", "BuildSketch"], + "fillet": ["BuildPart", "BuildSketch", "BuildLine"], "loft": ["BuildPart"], + "make_brake_formed": ["BuildPart"], "make_face": ["BuildSketch"], "make_hull": ["BuildSketch"], "mirror": ["BuildPart", "BuildSketch", "BuildLine"], "offset": ["BuildPart", "BuildSketch", "BuildLine"], + "project": ["BuildPart", "BuildSketch", "BuildLine"], + "project_workplane": ["BuildPart"], "revolve": ["BuildPart"], "scale": ["BuildPart", "BuildSketch", "BuildLine"], "section": ["BuildPart"], "split": ["BuildPart", "BuildSketch", "BuildLine"], - "sweep": ["BuildPart"], + "sweep": ["BuildPart", "BuildSketch"], + "thicken": ["BuildPart"], } @@ -119,6 +131,12 @@ class Builder(ABC): Args: workplanes: sequence of Union[Face, Plane, Location]: set plane(s) to work on mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Attributes: + mode (Mode): builder's combination mode + workplanes (list[Plane]): active workplanes + builder_parent (Builder): build to pass objects to on exit + """ # Context variable used to by Objects and Operations to link to current builder instance @@ -128,6 +146,7 @@ class Builder(ABC): # Abstract class variables _tag = "Builder" + _obj_name = "None" _shape = None _sub_class = None @@ -142,19 +161,26 @@ def max_dimension(self) -> float: """Maximum size of object in all directions""" return self._obj.bounding_box().diagonal if self._obj else 0.0 + @property + def new_edges(self) -> ShapeList(Edge): + """Edges that changed during last operation""" + return new_edges(*([self.obj_before] + self.to_combine), combined=self._obj) + def __init__( self, *workplanes: Union[Face, Plane, Location], mode: Mode = Mode.ADD, ): self.mode = mode - self.workplanes = workplanes + self.workplanes = WorkplaneList._convert_to_planes(workplanes) self._reset_tok = None self._python_frame = inspect.currentframe().f_back.f_back self.builder_parent = None self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []} self.workplanes_context = None self.exit_workplanes = None + self.obj_before: Shape = None + self.to_combine: list[Shape] = None def __enter__(self): """Upon entering record the parent and a token to restore contextvars""" @@ -185,21 +211,30 @@ def __enter__(self): ) # If there are no workplanes, create a default XY plane - if not self.workplanes and not WorkplaneList._get_context(): - self.workplanes_context = Workplanes(Plane.XY).__enter__() - elif self.workplanes: - self.workplanes_context = Workplanes(*self.workplanes).__enter__() + if self.workplanes: + self.workplanes_context = WorkplaneList(*self.workplanes).__enter__() + else: + self.workplanes_context = WorkplaneList(Plane.XY).__enter__() return self + def _exit_extras(self): + """Any builder specific exit actions""" + def __exit__(self, exception_type, exception_value, traceback): """Upon exiting restore context and send object to parent""" self._current.reset(self._reset_tok) + self._exit_extras() # custom builder exit code + if self.builder_parent is not None and self.mode != Mode.PRIVATE: logger.debug( "Transferring object(s) to %s", type(self.builder_parent).__name__ ) + if self._obj is None and not sys.exc_info()[1]: + raise RuntimeError( + f"{self._obj_name} is None - {self._tag} didn't create anything" + ) self.builder_parent._add_to_context(self._obj, mode=self.mode) self.exit_workplanes = WorkplaneList._get_context().workplanes @@ -261,6 +296,8 @@ def _add_to_context( ValueError: Nothing to intersect with ValueError: Nothing to intersect with """ + self.obj_before = self._obj + self.to_combine = list(objects) if mode != Mode.PRIVATE and len(objects) > 0: # Categorize the input objects by type typed = {} @@ -296,14 +333,12 @@ def _add_to_context( except: plane = Plane(origin=(0, 0, 0), z_dir=face.normal_at()) - reoriented_face = plane.to_local_coords(face) - aligned.append( - reoriented_face.moved( - Location((0, 0, -reoriented_face.center().Z)) - ) - ) - else: + face: Face = plane.to_local_coords(face) + face.move(Location((0, 0, -face.center().Z))) + if face.normal_at().Z > 0: # Flip the face if up-side-down aligned.append(face) + else: + aligned.append(-face) typed[Face] = aligned # Convert wires to edges @@ -404,7 +439,7 @@ def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]: select (Select, optional): Vertex selector. Defaults to Select.ALL. Returns: - VertexList[Vertex]: Vertices extracted + ShapeList[Vertex]: Vertices extracted """ vertex_list: list[Vertex] = [] if select == Select.ALL: @@ -412,12 +447,31 @@ def vertices(self, select: Select = Select.ALL) -> ShapeList[Vertex]: vertex_list.extend(edge.vertices()) elif select == Select.LAST: vertex_list = self.lasts[Vertex] + elif select == Select.NEW: + raise ValueError("Select.NEW only valid for edges") else: raise ValueError( f"Invalid input, must be one of Select.{Select._member_names_}" ) return ShapeList(set(vertex_list)) + def vertex(self, select: Select = Select.ALL) -> Vertex: + """Return Vertex + + Return a vertex. + + Args: + select (Select, optional): Vertex selector. Defaults to Select.ALL. + + Returns: + Vertex: Vertex extracted + """ + vertices = self.vertices(select) + vertex_count = len(vertices) + if vertex_count != 1: + warnings.warn(f"Found {vertex_count} vertices, returning first") + return vertices[0] + def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]: """Return Edges @@ -433,12 +487,31 @@ def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]: edge_list = self._obj.edges() elif select == Select.LAST: edge_list = self.lasts[Edge] + elif select == Select.NEW: + edge_list = self.new_edges else: raise ValueError( f"Invalid input, must be one of Select.{Select._member_names_}" ) return ShapeList(edge_list) + def edge(self, select: Select = Select.ALL) -> Edge: + """Return Edge + + Return an edge. + + Args: + select (Select, optional): Edge selector. Defaults to Select.ALL. + + Returns: + Edge: Edge extracted + """ + edges = self.edges(select) + edge_count = len(edges) + if edge_count != 1: + warnings.warn(f"Found {edge_count} edges, returning first") + return edges[0] + def wires(self, select: Select = Select.ALL) -> ShapeList[Wire]: """Return Wires @@ -454,12 +527,31 @@ def wires(self, select: Select = Select.ALL) -> ShapeList[Wire]: wire_list = self._obj.wires() elif select == Select.LAST: wire_list = Wire.combine(self.lasts[Edge]) + elif select == Select.NEW: + raise ValueError("Select.NEW only valid for edges") else: raise ValueError( f"Invalid input, must be one of Select.{Select._member_names_}" ) return ShapeList(wire_list) + def wire(self, select: Select = Select.ALL) -> Wire: + """Return Wire + + Return a wire. + + Args: + select (Select, optional): Wire selector. Defaults to Select.ALL. + + Returns: + Wire: Wire extracted + """ + wires = self.wires(select) + wire_count = len(wires) + if wire_count != 1: + warnings.warn(f"Found {wire_count} wires, returning first") + return wires[0] + def faces(self, select: Select = Select.ALL) -> ShapeList[Face]: """Return Faces @@ -475,12 +567,32 @@ def faces(self, select: Select = Select.ALL) -> ShapeList[Face]: face_list = self._obj.faces() elif select == Select.LAST: face_list = self.lasts[Face] + elif select == Select.NEW: + raise ValueError("Select.NEW only valid for edges") else: raise ValueError( f"Invalid input, must be one of Select.{Select._member_names_}" ) return ShapeList(face_list) + def face(self, select: Select = Select.ALL) -> Face: + """Return Face + + Return a face. + + Args: + select (Select, optional): Face selector. Defaults to Select.ALL. + + Returns: + Face: Face extracted + """ + faces = self.faces(select) + face_count = len(faces) + if face_count != 1: + msg = f"Found {face_count} faces, returning first" + warnings.warn(msg) + return faces[0] + def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]: """Return Solids @@ -496,12 +608,31 @@ def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]: solid_list = self._obj.solids() elif select == Select.LAST: solid_list = self.lasts[Solid] + elif select == Select.NEW: + raise ValueError("Select.NEW only valid for edges") else: raise ValueError( f"Invalid input, must be one of Select.{Select._member_names_}" ) return ShapeList(solid_list) + def solid(self, select: Select = Select.ALL) -> Solid: + """Return Solid + + Return a solid. + + Args: + select (Select, optional): Solid selector. Defaults to Select.ALL. + + Returns: + Solid: Solid extracted + """ + solids = self.solids(select) + solid_count = len(solids) + if solid_count != 1: + warnings.warn(f"Found {solid_count} solids, returning first") + return solids[0] + def _shapes(self, obj_type: Union[Vertex, Edge, Face, Solid] = None) -> ShapeList: """Extract Shapes""" obj_type = self._shape if obj_type is None else obj_type @@ -536,32 +667,25 @@ def validate_inputs( f"{validating_class.__class__.__name__} object or operation " f"({validating_class.__class__.__name__} applies to {validating_class._applies_to})" ) - elif ( + if ( isinstance(validating_class, str) and self.__class__.__name__ not in operations_apply_to[validating_class] ): raise RuntimeError( - f"{self.__class__.__name__} doesn't have a " - f"{validating_class} object or operation " - f"({validating_class} applies to {operations_apply_to[validating_class]})" + f"({validating_class} doesn't apply to {operations_apply_to[validating_class]})" ) # Check for valid object inputs for obj in objects: + operation = ( + validating_class + if isinstance(validating_class, str) + else validating_class.__class__.__name__ + ) if obj is None: pass - elif isinstance(obj, Builder): - raise RuntimeError( - f"{validating_class.__class__.__name__} doesn't accept Builders as input," - f" did you intend <{obj.__class__.__name__}>.{obj._obj_name}?" - ) - elif isinstance(obj, list): - raise RuntimeError( - f"{validating_class.__class__.__name__} doesn't accept {type(obj).__name__}," - f" did you intend *{obj}?" - ) elif not isinstance(obj, Shape): raise RuntimeError( - f"{validating_class.__class__.__name__} doesn't accept {type(obj).__name__}," + f"{operation} doesn't accept {type(obj).__name__}," f" did you intend ={obj}?" ) @@ -600,7 +724,7 @@ def locations(self) -> list[Location]: context = WorkplaneList._get_context() workplanes = context.workplanes if context else [Plane.XY] global_locations = [ - plane.to_location() * local_location + plane.location * local_location for plane in workplanes for local_location in self.local_locations ] @@ -666,12 +790,20 @@ class HexLocations(LocationList): plus one half the spacing between the circles. Args: - apothem: radius of the inscribed circle - xCount: number of points ( > 0 ) - yCount: number of points ( > 0 ) + apothem (float): radius of the inscribed circle + xCount (int): number of points ( > 0 ) + yCount (int): number of points ( > 0 ) align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER). + Atributes: + apothem (float): radius of the inscribed circle + xCount (int): number of points ( > 0 ) + yCount (int): number of points ( > 0 ) + align (Union[Align, tuple[Align, Align]]): align min, center, or max of object. + diagonal (float): major radius + local_locations (list{Location}): locations relative to workplane + Raises: ValueError: Spacing and count must be > 0 """ @@ -751,6 +883,9 @@ class PolarLocations(LocationList): angular_range (float, optional): magnitude of array from start angle. Defaults to 360.0. rotate (bool, optional): Align locations with arc tangents. Defaults to True. + Atributes: + local_locations (list{Location}): locations relative to workplane + Raises: ValueError: Count must be greater than or equal to 1 """ @@ -791,6 +926,10 @@ class Locations(LocationList): Args: pts (Union[VectorLike, Vertex, Location]): sequence of points to push + + Atributes: + local_locations (list{Location}): locations relative to workplane + """ def __init__(self, *pts: Union[VectorLike, Vertex, Location, Face, Plane, Axis]): @@ -807,7 +946,7 @@ def __init__(self, *pts: Union[VectorLike, Vertex, Location, Face, Plane, Axis]) elif isinstance(point, Plane): local_locations.append(Location(point)) elif isinstance(point, Axis): - local_locations.append(point.to_location()) + local_locations.append(point.location) elif isinstance(point, Face): local_locations.append(Location(Plane(point))) else: @@ -859,6 +998,15 @@ class GridLocations(LocationList): align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER). + + Attributes: + x_spacing (float): horizontal spacing + y_spacing (float): vertical spacing + x_count (int): number of horizontal points + y_count (int): number of vertical points + align (Union[Align, tuple[Align, Align]]): align min, center, or max of object. + local_locations (list{Location}): locations relative to workplane + Raises: ValueError: Either x or y count must be greater than or equal to one. """ @@ -915,7 +1063,10 @@ class WorkplaneList: at all time. Args: - planes (list[Plane]): list of planes + workplanes (sequence of Union[Face, Plane, Location]): objects to become planes + + Attributes: + workplanes (list[Plane]): list of workplanes """ @@ -924,12 +1075,25 @@ class WorkplaneList: "WorkplaneList._current" ) - def __init__(self, planes: list[Plane]): + def __init__(self, *workplanes: Union[Face, Plane, Location]): self._reset_tok = None - self.workplanes = planes + self.workplanes = WorkplaneList._convert_to_planes(workplanes) self.locations_context = None self.plane_index = 0 + @staticmethod + def _convert_to_planes(objs: Iterable[Union[Face, Plane, Location]]) -> list[Plane]: + """Translate objects to planes""" + planes = [] + for obj in objs: + if isinstance(obj, Plane): + planes.append(obj) + elif isinstance(obj, (Location, Face)): + planes.append(Plane(obj)) + else: + raise ValueError(f"WorkplaneList does not accept {type(obj)}") + return planes + def __enter__(self): """Upon entering create a token to restore contextvars""" self._reset_tok = self._current.set(self) @@ -998,66 +1162,26 @@ def localize(cls, *points: VectorLike) -> Union[list[Vector], Vector]: return result -class Workplanes(WorkplaneList): - """Workplane Context: Workplanes - - Create workplanes from the given sequence of planes. - - Args: - objs (Union[Face, Plane, Location]): sequence of faces, planes, or - locations to use to define workplanes. - Raises: - ValueError: invalid input - """ - - def __init__(self, *objs: Union[Face, Plane, Location]): - # warnings.warn( - # "Workplanes may be deprecated - Post on Discord to save it", - # DeprecationWarning, - # stacklevel=2, - # ) - self.workplanes = [] - for obj in objs: - if isinstance(obj, Plane): - self.workplanes.append(obj) - elif isinstance(obj, (Location, Face)): - self.workplanes.append(Plane(obj)) - else: - raise ValueError(f"Workplanes does not accept {type(obj)}") - super().__init__(self.workplanes) - - # # To avoid import loops, Vector add & sub are monkey-patched -def _vector_add(self: Vector, vec: VectorLike) -> Vector: - """Mathematical addition function where tuples are localized if workplane exists""" - if isinstance(vec, Vector): - result = Vector(self.wrapped.Added(vec.wrapped)) - elif isinstance(vec, tuple) and WorkplaneList._get_context(): - # type: ignore[union-attr] - result = Vector(self.wrapped.Added(WorkplaneList.localize(vec).wrapped)) - elif isinstance(vec, tuple): - result = Vector(self.wrapped.Added(Vector(vec).wrapped)) - else: - raise ValueError("Only Vectors or tuples can be added to Vectors") - - return result -def _vector_sub(self: Vector, vec: VectorLike) -> Vector: - """Mathematical subtraction function where tuples are localized if workplane exists""" - if isinstance(vec, Vector): - result = Vector(self.wrapped.Subtracted(vec.wrapped)) - elif isinstance(vec, tuple) and WorkplaneList._get_context(): - # type: ignore[union-attr] - result = Vector(self.wrapped.Subtracted(WorkplaneList.localize(vec).wrapped)) - elif isinstance(vec, tuple): - result = Vector(self.wrapped.Subtracted(Vector(vec).wrapped)) - else: - raise ValueError("Only Vectors or tuples can be subtracted from Vectors") +def _vector_add_sub_wrapper(original_op: Callable[[Vector, VectorLike], Vector]): + def wrapper(self: Vector, vec: VectorLike): + if isinstance(vec, tuple): + try: + # Relative adds must take into consideration planes with non-zero origins + origin = WorkplaneList._get_context().workplanes[0].origin + vec = WorkplaneList.localize(vec) - origin # type: ignore[union-attr] + except AttributeError: + # raised from `WorkplaneList._get_context().workplanes[0]` when context is `None` + # TODO make a specific `NoContextError` and raise that from `_get_context()` ? + pass + return original_op(self, vec) - return result + return wrapper -Vector.add = _vector_add # type: ignore[assignment] -Vector.sub = _vector_sub # type: ignore[assignment] +logger.debug("monkey-patching `Vector.add` and `Vector.sub`") +Vector.add = _vector_add_sub_wrapper(Vector.add) +Vector.sub = _vector_add_sub_wrapper(Vector.sub) diff --git a/src/build123d/build_enums.py b/src/build123d/build_enums.py index b825f19b..7fad1c36 100644 --- a/src/build123d/build_enums.py +++ b/src/build123d/build_enums.py @@ -106,6 +106,17 @@ def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" +class HeadType(Enum): + """Arrow head types""" + + STRAIGHT = auto() + CURVED = auto() + FILLETED = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class Keep(Enum): """Split options""" @@ -163,6 +174,50 @@ def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" +class MeshType(Enum): + """3MF mesh types typically for 3D printing""" + + OTHER = auto() + MODEL = auto() + SUPPORT = auto() + SOLIDSUPPORT = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + +class NumberDisplay(Enum): + """Methods for displaying numbers""" + + DECIMAL = auto() + FRACTION = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + +class PageSize(Enum): + """Align object about Axis""" + + A0 = auto() + A1 = auto() + A2 = auto() + A3 = auto() + A4 = auto() + A5 = auto() + A6 = auto() + A7 = auto() + A8 = auto() + A9 = auto() + A10 = auto() + LETTER = auto() + LEGAL = auto() + LEDGER = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + class PositionMode(Enum): """Position along curve mode""" @@ -174,10 +229,22 @@ def __repr__(self): class Select(Enum): - """Selector scope - all or last operation""" + """Selector scope - all, last operation or new objects""" ALL = auto() LAST = auto() + NEW = auto() + + def __repr__(self): + return f"<{self.__class__.__name__}.{self.name}>" + + +class Side(Enum): + """2D Offset types""" + + LEFT = auto() + RIGHT = auto() + BOTH = auto() def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" @@ -210,12 +277,12 @@ def __repr__(self): class Unit(Enum): """Standard Units""" - MICRO = auto() - MILLIMETER = auto() - CENTIMETER = auto() - METER = auto() - INCH = auto() - FOOT = auto() + MC = auto() # MICRO + MM = auto() # MILLIMETER + CM = auto() # CENTIMETER + M = auto() # METER + IN = auto() # INCH + FT = auto() # FOOT def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" @@ -226,6 +293,8 @@ class Until(Enum): NEXT = auto() LAST = auto() + PREVIOUS = auto() + FIRST = auto() def __repr__(self): return f"<{self.__class__.__name__}.{self.name}>" diff --git a/src/build123d/build_line.py b/src/build123d/build_line.py index e469deeb..4cdc7241 100644 --- a/src/build123d/build_line.py +++ b/src/build123d/build_line.py @@ -30,10 +30,9 @@ from typing import Union from build123d.build_common import Builder, WorkplaneList, logger -from build123d.build_enums import Mode, Select -from build123d.build_sketch import BuildSketch +from build123d.build_enums import Mode from build123d.geometry import Location, Plane -from build123d.topology import Curve, Edge, Face, ShapeList, Wire, Vertex +from build123d.topology import Curve, Edge, Face class BuildLine(Builder): @@ -82,8 +81,6 @@ def __init__( workplane: Union[Face, Plane, Location] = Plane.XY, mode: Mode = Mode.ADD, ): - self.initial_plane = workplane - self.mode = mode self.line: Curve = None super().__init__(workplane, mode=mode) @@ -91,25 +88,19 @@ def __exit__(self, exception_type, exception_value, traceback): """Upon exiting restore context and send object to parent""" self._current.reset(self._reset_tok) - if self.builder_parent is not None and self.mode != Mode.PRIVATE: + if ( + self.builder_parent is not None + and self.mode != Mode.PRIVATE + and self.line is not None + ): logger.debug( "Transferring object(s) to %s", type(self.builder_parent).__name__ ) - if ( - isinstance(self.builder_parent, BuildSketch) - and self.initial_plane != Plane.XY - ): - logger.debug( - "Realigning object(s) to Plane.XY for transfer to BuildSketch" - ) - realigned = self.initial_plane.to_local_coords(self.line) - self.builder_parent._add_to_context(realigned, mode=self.mode) - else: - self.builder_parent._add_to_context(self.line, mode=self.mode) + self.builder_parent._add_to_context(self.line, mode=self.mode) self.exit_workplanes = WorkplaneList._get_context().workplanes - # Now that the object has been transferred, it's save to remove any (non-default) + # Now that the object has been transferred, it's safe to remove any (non-default) # workplanes that were created then exit if self.workplanes: self.workplanes_context.__exit__(None, None, None) @@ -120,10 +111,18 @@ def faces(self, *args): """faces() not implemented""" raise NotImplementedError("faces() doesn't apply to BuildLine") + def face(self, *args): + """face() not implemented""" + raise NotImplementedError("face() doesn't apply to BuildLine") + def solids(self, *args): """solids() not implemented""" raise NotImplementedError("solids() doesn't apply to BuildLine") + def solid(self, *args): + """solid() not implemented""" + raise NotImplementedError("solid() doesn't apply to BuildLine") + def _add_to_pending(self, *objects: Union[Edge, Face], face_plane: Plane = None): """_add_to_pending not implemented""" raise NotImplementedError("_add_to_pending doesn't apply to BuildLine") diff --git a/src/build123d/build_part.py b/src/build123d/build_part.py index de75ac06..3fb97654 100644 --- a/src/build123d/build_part.py +++ b/src/build123d/build_part.py @@ -35,7 +35,7 @@ from build123d.build_common import Builder, logger from build123d.build_enums import Mode from build123d.geometry import Location, Plane -from build123d.topology import Compound, Edge, Face, Part, Solid, Wire +from build123d.topology import Edge, Face, Joint, Part, Solid, Wire class BuildPart(Builder): @@ -71,13 +71,18 @@ def pending_edges_as_wire(self) -> Wire: """Return a wire representation of the pending edges""" return Wire.combine(self.pending_edges)[0] + @property + def location(self) -> Location: + """Builder's location""" + return self.part.location if self.part is not None else Location() + def __init__( self, *workplanes: Union[Face, Plane, Location], mode: Mode = Mode.ADD, ): + self.joints: dict[str, Joint] = {} self.part: Part = None - self.initial_planes = workplanes self.pending_faces: list[Face] = [] self.pending_face_planes: list[Plane] = [] self.pending_planes: list[Plane] = [] @@ -107,3 +112,10 @@ def _add_to_pending(self, *objects: Union[Edge, Face], face_plane: Plane = None) edge.location, ) self.pending_edges.append(edge) + + def _exit_extras(self): + """Transfer joints on exit""" + if self.joints: + self.part.joints = self.joints + for joint in self.part.joints.values(): + joint.parent = self.part diff --git a/src/build123d/build_sketch.py b/src/build123d/build_sketch.py index a18a99a5..a93ed848 100644 --- a/src/build123d/build_sketch.py +++ b/src/build123d/build_sketch.py @@ -90,7 +90,6 @@ def __init__( *workplanes: Union[Face, Plane, Location], mode: Mode = Mode.ADD, ): - self.workplanes = workplanes self.mode = mode self.sketch_local: Sketch = None self.pending_edges: ShapeList[Edge] = ShapeList() @@ -100,6 +99,10 @@ def solids(self, *args): """solids() not implemented""" raise NotImplementedError("solids() doesn't apply to BuildSketch") + def solid(self, *args): + """solid() not implemented""" + raise NotImplementedError("solid() doesn't apply to BuildSketch") + def consolidate_edges(self) -> Union[Wire, list[Wire]]: """Unify pending edges into one or more Wires""" wires = Wire.combine(self.pending_edges) diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py new file mode 100644 index 00000000..2a141eee --- /dev/null +++ b/src/build123d/drafting.py @@ -0,0 +1,778 @@ +""" +Drafting Objects + +name: drafting.py +by: Gumyr +date: September 16th 2023 + +desc: + This python module contains objects using in building technical drawings as Sketches. + +license: + + Copyright 2022 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from dataclasses import dataclass +from datetime import date +from math import copysign, floor, gcd, log2, pi +from typing import ClassVar, Iterable, Optional, Union + +from build123d.build_common import IN, MM +from build123d.build_enums import ( + Align, + FontStyle, + GeomType, + HeadType, + Mode, + NumberDisplay, + PageSize, + Side, + Unit, +) +from build123d.build_line import BuildLine +from build123d.build_sketch import BuildSketch +from build123d.geometry import Axis, Color, Location, Plane, Pos, Vector, VectorLike +from build123d.objects_curve import Line, TangentArc +from build123d.objects_sketch import BaseSketchObject, Polygon, Text +from build123d.operations_generic import fillet, mirror, sweep +from build123d.operations_sketch import make_face, trace +from build123d.topology import Compound, Curve, Edge, Sketch, Vertex, Wire + + +class ArrowHead(BaseSketchObject): + """Sketch Object: ArrowHead + + Args: + size (float): tip to tail length + head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED. + rotation (float, optional): rotation in degrees. Defaults to 0. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + """ + + _applies_to = [BuildSketch._tag] + + def __init__( + self, + size: float, + head_type: HeadType = HeadType.CURVED, + rotation: float = 0, + mode: Mode = Mode.ADD, + ): + with BuildSketch() as arrow_head: + if head_type == HeadType.CURVED: + with BuildLine(): + side = TangentArc( + (0, 0), (-size, size / 3), tangent=(-size, size / 6) + ) + Line(side @ 1, (-7 * size / 8, 0)) + mirror(about=Plane.XZ) + make_face() + elif head_type == HeadType.STRAIGHT: + Polygon((-size, size / 3), (-size, -size / 3), (0, 0), align=None) + elif head_type == HeadType.FILLETED: + ArrowHead(size, head_type=HeadType.CURVED) + fillet( + arrow_head.vertices().filter_by_position( + Axis.X, -2 * size, -size / 5 + ), + radius=size / 20, + ) + + super().__init__(arrow_head.sketch, rotation=rotation, align=None, mode=mode) + + +class Arrow(BaseSketchObject): + """Sketch Object: Arrow with shaft + + Args: + arrow_size (float): arrow head tip to tail length + shaft_path (Union[Edge, Wire]): line describing the shaft shape + shaft_width (float): line width of shaft + head_at_start (bool, optional): Defaults to True. + head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED. + mode (Mode, optional): _description_. Defaults to Mode.ADD. + """ + + _applies_to = [BuildSketch._tag] + + def __init__( + self, + arrow_size: float, + shaft_path: Union[Edge, Wire], + shaft_width: float, + head_at_start: bool = True, + head_type: HeadType = HeadType.CURVED, + mode: Mode = Mode.ADD, + ): + angle = ( + shaft_path.tangent_angle_at(0) + 180 + if head_at_start + else shaft_path.tangent_angle_at(1) + ) + # Create the arrow head + arrow_head = ArrowHead( + size=arrow_size, rotation=angle, head_type=head_type, mode=Mode.PRIVATE + ).moved(Location(shaft_path.position_at(int(not head_at_start)))) + + # Trim the path so the tip of the arrow isn't lost + trim_amount = (arrow_size / 2) / shaft_path.length + if head_at_start: + shaft_path = shaft_path.trim(trim_amount, 1.0) + else: + shaft_path = shaft_path.trim(0.0, 1.0 - trim_amount) + + # Create a perpendicular line to sweep the tail path + shaft_pen = shaft_path.perpendicular_line(shaft_width) + shaft = sweep(shaft_pen, shaft_path, mode=Mode.PRIVATE) + + arrow = arrow_head.fuse(shaft).clean() + + super().__init__(arrow, rotation=0, align=None, mode=mode) + + +PathDescriptor = Union[ + Wire, + Edge, + list[Union[Vector, Vertex, tuple[float, float, float]]], +] +PointLike = Union[Vector, Vertex, tuple[float, float, float]] + + +@dataclass +class Draft: + """Draft + + Documenting build123d designs with dimension and extension lines as well as callouts. + + + Args: + font_size (float): size of the text in dimension lines and callouts. Defaults to 5.0. + font (str): font to use for text. Defaults to "Arial". + font_style: text style. Defaults to FontStyle.REGULAR. + head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED. + arrow_length (float): arrow head length. Defaults to 3.0. + line_width (float): thickness of all lines. Defaults to 0.5. + pad_around_text (float): amount of padding around text. Defaults to 2.0. + unit (Unit): measurement unit. Defautls to Unit.MM. + number_display (NumberDisplay): numbers as decimal or fractions. + Default to NumberDisplay.DECIMAL. + display_units (bool): control the display of units with numbers. Defaults to True. + decimal_precision (int): number of decimal places when displaying numbers. Defaults to 2. + fractional_precision (int): maximum fraction denominator - must be a factor of 2. + Defaults to 64. + extension_gap (float): gap between the point and start of extension line in extension_line. + Defaults to 2.0. + + """ + + # Class Attributes + unit_LUT: ClassVar[dict] = {True: "mm", False: '"'} + + font_size: float = 5.0 + font: str = "Arial" + font_style: FontStyle = FontStyle.REGULAR + head_type: HeadType = HeadType.CURVED + arrow_length: float = 3.0 + line_width: float = 0.5 + pad_around_text: float = 2.0 + unit: Unit = Unit.MM + number_display: NumberDisplay = NumberDisplay.DECIMAL + display_units: bool = True + decimal_precision: int = 2 + fractional_precision: int = 64 + extension_gap: float = 2.0 + + @property + def is_metric(self) -> bool: + """Are metric units being used""" + return self.unit in [Unit.MM, Unit.CM, Unit.M, Unit.MC] + + def __post_init__(self): + """Validate inputs""" + if not log2(self.fractional_precision).is_integer(): + raise ValueError( + f"fractional_precision values must be a factor of 2 not {self.fractional_precision}" + ) + + def _round_to_str(self, number: float) -> str: + """Round a float but remove decimal if appropriate and convert to str""" + return ( + f"{round(number, self.decimal_precision):.{self.decimal_precision}f}" + if self.decimal_precision > 0 + else str(int(round(number, self.decimal_precision))) + ) + + def _number_with_units( + self, + number: float, + tolerance: Union[float, tuple[float, float]] = None, + display_units: Optional[bool] = None, + ) -> str: + """Convert a raw number to a unit of measurement string based on the class settings""" + + def simplify_fraction(numerator: int, denominator: int) -> tuple[int, int]: + """Mathematically simplify a fraction given a numerator and demoninator""" + greatest_common_demoninator = gcd(numerator, denominator) + return ( + int(numerator / greatest_common_demoninator), + int(denominator / greatest_common_demoninator), + ) + + if display_units is None: + if tolerance is None: + qualified_display_units = self.display_units + else: + qualified_display_units = False + else: + qualified_display_units = display_units + + unit_str = Draft.unit_LUT[self.is_metric] if qualified_display_units else "" + if tolerance is None: + tolerance_str = "" + elif isinstance(tolerance, float): + tolerance_str = f" ±{self._number_with_units(tolerance)}" + else: + tolerance_str = ( + f" +{self._number_with_units(tolerance[0],display_units=False)}" + f" -{self._number_with_units(tolerance[1])}" + ) + if self.is_metric or self.number_display == NumberDisplay.DECIMAL: + unit_lut = {True: MM, False: IN} + measurement = self._round_to_str(number / unit_lut[self.is_metric]) + return_value = f"{measurement}{unit_str}{tolerance_str}" + else: + whole_part = floor(number / IN) + (numerator, demoninator) = simplify_fraction( + round((number / IN - whole_part) * self.fractional_precision), + self.fractional_precision, + ) + if whole_part == 0: + return_value = f"{numerator}/{demoninator}{unit_str}{tolerance_str}" + else: + return_value = ( + f"{whole_part} {numerator}/{demoninator}{unit_str}{tolerance_str}" + ) + + return return_value + + @staticmethod + def _process_path(path: PathDescriptor) -> Union[Edge, Wire]: + """Convert a PathDescriptor into a Edge/Wire""" + if isinstance(path, (Edge, Wire)): + processed_path = path + elif isinstance(path, Iterable): + pnts = [ + Vector(p.to_tuple()) if isinstance(p, Vertex) else Vector(p) + for p in path + ] + if len(pnts) == 2: + processed_path = Edge.make_line(*pnts) + else: + processed_path = Wire.make_polygon(pnts, close=False) + else: + raise ValueError("Unsupported patch descriptor") + return processed_path + + def _label_to_str( + self, + label: str, + line_wire: Wire, + label_angle: bool, + tolerance: Optional[Union[float, tuple[float, float]]], + ) -> str: + """Create the str to use as the label text""" + line_length = line_wire.length + if label is not None: + label_str = label + elif label_angle: + arc_edges = line_wire.edges().filter_by(GeomType.CIRCLE) + if len(arc_edges) == 0: + raise ValueError( + "label_angle requested but the path is not part of a circle" + ) + arc_edge = arc_edges[0] + arc_size = 360 * line_length / (2 * pi * arc_edge.radius) + label_str = f"{self._round_to_str(arc_size)}°" + else: + label_str = self._number_with_units(line_length, tolerance) + return label_str + + @staticmethod + def _sketch_location( + path: Union[Edge, Wire], u_value: float, flip: bool = False + ) -> Location: + """Given a path on Plane.XY, determine the Location for object placement""" + angle = path.tangent_angle_at(u_value) + int(flip) * 180 + return Location(path.position_at(u_value), (0, 0, 1), angle) + + +class DimensionLine(BaseSketchObject): + """Sketch Object: DimensionLine + + Create a dimension line typically for internal measurements. + Typically used for (but not restricted to) inside dimensions, a dimension line often + as arrows on either side of a dimension or label. + + There are three options depending on the size of the text and length + of the dimension line: + Type 1) The label and arrows fit within the length of the path + Type 2) The text fit within the path and the arrows go outside + Type 3) Neither the text nor the arrows fit within the path + + Args: + path (PathDescriptor): a very general type of input used to describe the path the + dimension line will follow. + draft (Draft): instance of Draft dataclass + sketch (Sketch): the Sketch being created to check for possible overlaps. In builder + mode the active Sketch will be used if None is provided. + label (str, optional): a text string which will replace the length (or + arc length) that would otherwise be extracted from the provided path. Providing + a label is useful when illustrating a parameterized input where the name of an + argument is desired not an actual measurement. Defaults to None. + arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement + of the start and end arrows. Defaults to (True, True). + tolerance (Union[float, tuple[float, float]], optional): an optional tolerance + value to add to the extracted length value. If a single tolerance value is provided + it is shown as ± the provided value while a pair of values are shown as + separate + and - values. Defaults to None. + label_angle (bool, optional): a flag indicating that instead of an extracted length value, + the size of the circular arc extracted from the path should be displayed in degrees. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + ValueError: Only 2 points allowed for dimension lines + ValueError: No output - no arrows selected + + """ + + def __init__( + self, + path: PathDescriptor, + draft: Draft = None, + sketch: Sketch = None, + label: str = None, + arrows: tuple[bool, bool] = (True, True), + tolerance: Union[float, tuple[float, float]] = None, + label_angle: bool = False, + mode: Mode = Mode.ADD, + ) -> Sketch: + context = BuildSketch._get_context(self) + if sketch is None and not (context is None or context.sketch is None): + sketch = context.sketch + + # Create a wire modelling the path of the dimension lines from a variety of input types + if isinstance(path, Iterable) and len(path) > 2: + raise ValueError("Only two points are allowed for dimension lines") + path_obj = Draft._process_path(path) # Edge or Wire + path_length = path_obj.length + + self.dimension = path_length #: length of the dimension + + # Generate the label + label_str = draft._label_to_str(label, path_obj, label_angle, tolerance) + label_shape = Text( + txt=label_str, + font_size=draft.font_size, + font=draft.font, + font_style=draft.font_style, + align=Align.CENTER, + mode=Mode.PRIVATE, + ) + label_length = label_shape.bounding_box().size.X + + # Calculate the arrow shaft length for up to three types + if arrows.count(True) == 0: + raise ValueError("No output - no arrows selected") + elif label_length + arrows.count(True) * draft.arrow_length < path_length: + shaft_length = (path_length - label_length) / 2 - draft.pad_around_text + shaft_pair = [ + path_obj.trim(0.0, shaft_length / path_length), + path_obj.trim(1.0 - shaft_length / path_length, 1.0), + ] + else: + shaft_length = 2 * draft.arrow_length + shaft_pair = [ + Edge.make_line( + path_obj @ 0, + path_obj @ 0 - (path_obj % 0) * 2 * draft.arrow_length, + ), + Edge.make_line( + path_obj @ 1 + (path_obj % 1) * 2 * draft.arrow_length, + path_obj @ 1, + ), + ] + + arrow_shapes = [] + for i, shaft in enumerate(shaft_pair): + flip_head = (shaft.position_at(i) != path_obj.position_at(i)) == bool(i) + arrow_shapes.append( + Arrow( + draft.arrow_length, + shaft, + draft.line_width, + flip_head, + draft.head_type, + mode=Mode.PRIVATE, + ) + ) + # Calculate the possible locations for the label + overage = shaft_length + draft.pad_around_text + label_length / 2 + label_u_values = [0.5, -overage / path_length, 1 + overage / path_length] + + # d_lines = Sketch(children=arrows[0]) + d_lines = {} + # for arrow_pair in arrow_shapes: + for u_value in label_u_values: + d_line = Sketch() + for add_arrow, arrow_shape in zip(arrows, arrow_shapes): + if add_arrow: + d_line += arrow_shape + flip_label = path_obj.tangent_at(u_value).get_angle(Vector(1, 0, 0)) >= 180 + loc = Draft._sketch_location(path_obj, u_value, flip_label) + placed_label = label_shape.located(loc) + self_intersection = d_line.intersect(placed_label).area + d_line += placed_label + bbox_size = d_line.bounding_box().size + + # Minimize size while avoiding intersections + common_area = 0.0 if sketch is None else d_line.intersect(sketch).area + common_area += self_intersection + score = (d_line.area - 10 * common_area) / bbox_size.X + d_lines[d_line] = score + + # Sort by score to find the best option + d_lines = sorted(d_lines.items(), key=lambda x: x[1]) + + super().__init__(obj=d_lines[-1][0], rotation=0, align=None, mode=mode) + + +class ExtensionLine(BaseSketchObject): + """Sketch Object: Extension Line + + Create a dimension line with two lines extending outward from the part to dimension. + Typically used for (but not restricted to) outside dimensions, with a pair of lines + extending from the edge of a part to a dimension line. + + Args: + border (PathDescriptor): a very general type of input defining the object to + be dimensioned. Typically this value would be extracted from the part but is + not restricted to this use. + offset (float): a distance to displace the dimension line from the edge of the object + draft (Draft): instance of Draft dataclass + label (str, optional): a text string which will replace the length (or arc length) + that would otherwise be extracted from the provided path. Providing a label is + useful when illustrating a parameterized input where the name of an argument + is desired not an actual measurement. Defaults to None. + arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement + of the start and end arrows. Defaults to (True, True). + tolerance (Union[float, tuple[float, float]], optional): an optional tolerance + value to add to the extracted length value. If a single tolerance value is provided + it is shown as ± the provided value while a pair of values are shown as + separate + and - values. Defaults to None. + label_angle (bool, optional): a flag indicating that instead of an extracted length + value, the size of the circular arc extracted from the path should be displayed + in degrees. Defaults to False. + project_line (Vector, optional): Vector line which to project dimension against. + Defaults to None. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + """ + + def __init__( + self, + border: PathDescriptor, + offset: float, + draft: Draft, + sketch: Sketch = None, + label: str = None, + arrows: tuple[bool, bool] = (True, True), + tolerance: Union[float, tuple[float, float]] = None, + label_angle: bool = False, + project_line: VectorLike = None, + mode: Mode = Mode.ADD, + ): + context = BuildSketch._get_context(self) + if sketch is None and not (context is None or context.sketch is None): + sketch = context.sketch + if project_line is not None: + raise NotImplementedError("project_line is currently unsupported") + + # Create a wire modelling the path of the dimension lines from a variety of input types + object_to_measure = Draft._process_path(border) + + side_lut = {1: Side.RIGHT, -1: Side.LEFT} + + if offset == 0: + raise ValueError("A dimension line should be used if offset is 0") + dimension_path = object_to_measure.offset_2d( + distance=offset, side=side_lut[copysign(1, offset)], closed=False + ) + + extension_lines = [ + Edge.make_line( + object_to_measure.position_at(e), dimension_path.position_at(e) + ) + for e in [0, 1] + ] + # If the dimension path was created backwards, flip the extension lines + if abs(extension_lines[0].length - abs(offset)) > 1e-4: + extension_lines = [ + Edge.make_line( + object_to_measure.position_at(e), dimension_path.position_at(1 - e) + ) + for e in [0, 1] + ] + + # Move the extension lines away from the object + extension_lines = [ + extension_line.moved( + Location(extension_line.tangent_at(0) * draft.extension_gap) + ) + for extension_line in extension_lines + ] + + # Build the extension line sketch + e_lines = [] + for extension_line in extension_lines: + line_pen = extension_line.perpendicular_line(draft.line_width) + e_line_shape = sweep(line_pen, extension_line, mode=Mode.PRIVATE) + e_lines.append(e_line_shape) + d_line = DimensionLine( + dimension_path, + draft, + sketch, + label, + arrows, + tolerance, + label_angle, + mode=Mode.PRIVATE, + ) + self.dimension = d_line.dimension #: length of the dimension + + e_line_sketch = Sketch(children=e_lines + d_line.faces()) + + super().__init__(obj=e_line_sketch, rotation=0, align=None, mode=mode) + + +class TechnicalDrawing(BaseSketchObject): + """Sketch Object: TechnicalDrawing + + The border of a technical drawing with external frame and text box. + + Args: + designed_by (str, optional): Defaults to "build123d". + design_date (date, optional): Defaults to date.today(). + page_size (PageSize, optional): Defaults to PageSize.A4. + title (str, optional): drawing title. Defaults to "Title". + sub_title (str, optional): drawing sub title. Defaults to "Sub Title". + drawing_number (str, optional): Defaults to "B3D-1". + sheet_number (int, optional): Defaults to None. + drawing_scale (float, optional): displayes as 1:value. Defaults to 1.0. + nominal_text_size (float, optional): size of title text. Defaults to 10.0. + line_width (float, optional): Defaults to 0.5. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + """ + + page_sizes = { + PageSize.A0: (1189 * MM, 841 * MM), + PageSize.A1: (841 * MM, 594 * MM), + PageSize.A2: (594 * MM, 420 * MM), + PageSize.A3: (420 * MM, 297 * MM), + PageSize.A4: (297 * MM, 210 * MM), + PageSize.A5: (210 * MM, 148.5 * MM), + PageSize.A6: (148.5 * MM, 105 * MM), + PageSize.A7: (105 * MM, 74 * MM), + PageSize.A8: (74 * MM, 52 * MM), + PageSize.A9: (52 * MM, 37 * MM), + PageSize.A10: (37 * MM, 26 * MM), + PageSize.LETTER: (11 * IN, 8.5 * IN), + PageSize.LEGAL: (14 * IN, 8.5 * IN), + PageSize.LEDGER: (17 * IN, 11 * IN), + } + margin = 5 * MM + + def __init__( + self, + designed_by: str = "build123d", + design_date: date = date.today(), + page_size: PageSize = PageSize.A4, + title: str = "Title", + sub_title: str = "Sub Title", + drawing_number: str = "B3D-1", + sheet_number: int = None, + drawing_scale: float = 1.0, + nominal_text_size: float = 10.0, + line_width: float = 0.5, + mode: Mode = Mode.ADD, + ): + page_dim = TechnicalDrawing.page_sizes[page_size] + # Frame + frame_width = page_dim[0] - 2 * TechnicalDrawing.margin - 2 * nominal_text_size + frame_height = 2 * frame_width / 3 + frame_wire = Wire.make_polygon( + [ + (-frame_width / 2, frame_height / 2), + (frame_width / 2, frame_height / 2), + (frame_width / 2, -frame_height / 2), + (-frame_width / 2, -frame_height / 2), + ], + ) + frame = trace(frame_wire, line_width, mode=Mode.PRIVATE) + # Ticks + tick_lines = [] + for i in range(20): + if i in [0, 6, 10, 16]: # corners + continue + u_value = i / 20 + pos = frame_wire.position_at(u_value) + tick_lines.append( + Edge.make_line( + pos, + pos + + Vector(nominal_text_size, 0).rotate( + Axis.Z, frame_wire.tangent_angle_at(u_value) + 90 + ), + ) + ) + ticks = trace(tick_lines, line_width, mode=Mode.PRIVATE) + # Numbers + grid_labels = Sketch() + y_centers = {0: -3 / 8, 1: -1 / 8, 2: 1 / 8, 3: 3 / 8} + for label in range(4): + for x_index in [-0.5, 0.5]: + grid_labels += Pos( + x_index * (frame_width + 1.5 * nominal_text_size), + y_centers[label] * frame_height, + ) * Sketch( + Compound.make_text(str(label + 1), nominal_text_size).wrapped + ) + + # Letters + x_centers = { + 0: -5 / 12, + 1: -3 / 12, + 2: -1 / 12, + 3: 1 / 12, + 4: 3 / 12, + 5: 5 / 12, + } + for i, label in enumerate(["F", "E", "D", "C", "B", "A"]): + for y_index in [-0.5, 0.5]: + grid_labels += Pos( + x_centers[i] * frame_width, + y_index * (frame_height + 1.5 * nominal_text_size), + ) * Sketch(Compound.make_text(label, nominal_text_size).wrapped) + + # Text Box Frame + bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5 + bf_pnt2 = frame_wire.edges().sort_by(Axis.X)[-1] @ 0.75 + box_frame_curve = Wire.make_polygon( + [bf_pnt1, (bf_pnt1.X, bf_pnt2.Y), bf_pnt2], close=False + ) + bf_pnt3 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (1 / 3) + bf_pnt4 = box_frame_curve.edges().sort_by(Axis.X)[0] @ (2 / 3) + box_frame_curve += Edge.make_line(bf_pnt3, (bf_pnt2.X, bf_pnt3.Y)) + box_frame_curve += Edge.make_line(bf_pnt4, (bf_pnt2.X, bf_pnt4.Y)) + bf_pnt5 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (1 / 3) + bf_pnt6 = box_frame_curve.edges().sort_by(Axis.Y)[-1] @ (2 / 3) + box_frame_curve += Edge.make_line(bf_pnt5, (bf_pnt5.X, bf_pnt1.Y)) + start = Vector(bf_pnt6.X, bf_pnt1.Y) + box_frame_curve += Edge.make_line( + start, start + Vector(0, (bf_pnt2.Y - bf_pnt1.Y) / 3) + ) + box_frame = trace(box_frame_curve, line_width, mode=Mode.PRIVATE) + # Text + labels = Sketch() + t_base_line1 = Edge.make_line(bf_pnt1, (bf_pnt1.X, bf_pnt2.Y)).moved( + Location((nominal_text_size / 5, 0)) + ) + t_base_line2 = t_base_line1.moved(Location((frame_width / 6, 0))) + t_base_line3 = t_base_line1.moved(Location((2 * frame_width / 6, 0))) + labels += Pos(t_base_line1 @ (11 / 12)) * Sketch( + Compound.make_text( + "DESIGNED BY:", nominal_text_size / 3, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line1 @ (9 / 12)) * Sketch( + Compound.make_text( + designed_by, nominal_text_size / 2, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line1 @ (7 / 12)) * Sketch( + Compound.make_text( + "DATE:", nominal_text_size / 3, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line1 @ (5 / 12)) * Sketch( + Compound.make_text( + design_date.isoformat(), + nominal_text_size / 2, + align=(Align.MIN, Align.CENTER), + ).wrapped + ) + labels += Pos(t_base_line1 @ (3 / 12)) * Sketch( + Compound.make_text( + "SCALE:", nominal_text_size / 3, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line1 @ (1 / 12)) * Sketch( + Compound.make_text( + "1:" + str(drawing_scale), + nominal_text_size / 2, + align=(Align.MIN, Align.CENTER), + ).wrapped + ) + labels += Pos(t_base_line2 @ (10 / 12)) * Sketch( + Compound.make_text( + title, nominal_text_size, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line2 @ (6 / 12)) * Sketch( + Compound.make_text( + sub_title, nominal_text_size, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line2 @ (3 / 12)) * Sketch( + Compound.make_text( + "DRAWING NUMBER:", + nominal_text_size / 3, + align=(Align.MIN, Align.CENTER), + ).wrapped + ) + labels += Pos(t_base_line2 @ (1 / 12)) * Sketch( + Compound.make_text( + drawing_number, nominal_text_size / 2, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + labels += Pos(t_base_line3 @ (3 / 12)) * Sketch( + Compound.make_text( + "SHEET:", nominal_text_size / 3, align=(Align.MIN, Align.CENTER) + ).wrapped + ) + if sheet_number is not None: + labels += Pos(t_base_line3 @ (1 / 12)) * Sketch( + Compound.make_text( + str(sheet_number), + nominal_text_size / 2, + align=(Align.MIN, Align.CENTER), + ).wrapped + ) + + technical_drawing = Compound( + children=[frame, ticks, grid_labels, box_frame, labels] + ) + + super().__init__(obj=technical_drawing, rotation=0, align=None, mode=mode) diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 07a36e9b..d93c5d53 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -1,32 +1,79 @@ +""" +build123d exporters + +name: exporters.py +by: JRMobley +date: March 19th, 2023 + +desc: + This python module contains exporters for SVG and DXF file formats. + +license: + + Copyright 2023 JRMobley + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" + + # pylint has trouble with the OCP imports # pylint: disable=no-name-in-module, import-error -from build123d import * -from build123d import Shape -from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve # type: ignore -from OCP.GeomConvert import GeomConvert # type: ignore -from OCP.Geom import Geom_BSplineCurve, Geom_BezierCurve # type: ignore -from OCP.gp import gp_XYZ, gp_Pnt, gp_Vec, gp_Dir, gp_Ax2 # type: ignore -from OCP.BRepLib import BRepLib # type: ignore -from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape # type: ignore -from OCP.HLRAlgo import HLRAlgo_Projector # type: ignore -from typing import Callable, List, Union, Tuple, Dict, Optional -from typing_extensions import Self -import svgpathtools as PT +import math import xml.etree.ElementTree as ET from enum import Enum, auto +from typing import Callable, Iterable, Optional, Union, List +from copy import copy + import ezdxf +import svgpathtools as PT +from build123d.topology import ( + BoundBox, + Compound, + Edge, + Wire, + GeomType, + Shape, + Vector, + VectorLike, +) +from build123d.build_enums import Unit from ezdxf import zoom +from ezdxf.colors import RGB, aci2rgb from ezdxf.math import Vec2 -from ezdxf.colors import aci2rgb from ezdxf.tools.standards import linetypes as ezdxf_linetypes -import math +from OCP.BRepLib import BRepLib # type: ignore +from OCP.BRepTools import BRepTools_WireExplorer # type: ignore +from OCP.Geom import Geom_BezierCurve # type: ignore +from OCP.GeomConvert import GeomConvert # type: ignore +from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve # type: ignore +from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ # type: ignore +from OCP.HLRAlgo import HLRAlgo_Projector # type: ignore +from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape # type: ignore +from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum # type: ignore +from OCP.TopExp import TopExp_Explorer # type: ignore +from typing_extensions import Self + +PathSegment = Union[PT.Line, PT.Arc, PT.QuadraticBezier, PT.CubicBezier] # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -class Drawing(object): +class Drawing: + """A base drawing object""" + def __init__( self, shape: Shape, @@ -37,7 +84,6 @@ def __init__( with_hidden: bool = True, focus: Union[float, None] = None, ): - hlr = HLRBRep_Algo() hlr.Add(shape.wrapped) @@ -75,7 +121,6 @@ def __init__( hidden = [] if with_hidden: - hidden_sharp_edges = hlr_shapes.HCompound() if not hidden_sharp_edges.IsNull(): hidden.append(hidden_sharp_edges) @@ -102,30 +147,43 @@ def __init__( # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- -# class AutoNameEnum(Enum): -# def _generate_next_value_(name, start, count, last_values): -# return name +class AutoNameEnum(Enum): + @staticmethod + def _generate_next_value_(name, start, count, last_values): + return name + + +class LineType(AutoNameEnum): + """Line Types""" -class LineType(Enum): CONTINUOUS = auto() - CENTERX2 = auto() + + BORDER = auto() + BORDER2 = auto() + BORDERX2 = auto() + CENTER = auto() CENTER2 = auto() - DASHED = auto() - DASHEDX2 = auto() - DASHED2 = auto() - PHANTOM = auto() - PHANTOMX2 = auto() - PHANTOM2 = auto() + CENTERX2 = auto() DASHDOT = auto() - DASHDOTX2 = auto() DASHDOT2 = auto() - DOT = auto() - DOTX2 = auto() - DOT2 = auto() + DASHDOTX2 = auto() + DASHED = auto() + DASHED2 = auto() + DASHEDX2 = auto() DIVIDE = auto() - DIVIDEX2 = auto() DIVIDE2 = auto() + DIVIDEX2 = auto() + DOT = auto() + DOT2 = auto() + DOTX2 = auto() + HIDDEN = auto() + HIDDEN2 = auto() + HIDDENX2 = auto() + PHANTOM = auto() + PHANTOM2 = auto() + PHANTOMX2 = auto() + ISO_DASH = "ACAD_ISO02W100" # __ __ __ __ __ __ __ __ __ __ __ __ __ ISO_DASH_SPACE = "ACAD_ISO03W100" # __ __ __ __ __ __ ISO_LONG_DASH_DOT = "ACAD_ISO04W100" # ____ . ____ . ____ . ____ . _ @@ -143,6 +201,8 @@ class LineType(Enum): class ColorIndex(Enum): + """Colors""" + RED = 1 YELLOW = 2 GREEN = 3 @@ -154,11 +214,36 @@ class ColorIndex(Enum): LIGHT_GRAY = 9 -def lin_pattern(*args): - """Convert an ISO line pattern from the values found in a standard - AutoCAD .lin file to the values expected by ezdxf. Specifically, - prepend the sum of the absolute values of the lengths, and divide - by 2.54 to convert the units from mm to 1/10in.""" +class DotLength(Enum): + """Line type dash pattern dot widths, expressed in tenths of an inch.""" + + TRUE_DOT = 0.0 + """A true, circular dot which renders properly in web browsers.""" + + INKSCAPE_COMPAT = 0.01 + """A very, very short segment which will render in Inkscape but still + look like a circle.""" + + QCAD_IMPERIAL = 0.2 + """A visibly elongated dot which will match QCAD rendering of + documents that use the imperial measurement system.""" + + +def ansi_pattern(*args): + """Prepare an ANSI line pattern for ezdxf usage. + Input pattern is specified in inches. + Output is given in tenths of an inch, and the total pattern length + is prepended to the list.""" + abs_args = [abs(l) for l in args] + result = [(l * 10) for l in [sum(abs_args), *args]] + return result + + +def iso_pattern(*args): + """Prepare an ISO line pattern for ezdxf usage. + Input pattern is specified in millimeters. + Output is given in tenths of an inch, and the total pattern length + is prepended to the list.""" abs_args = [abs(l) for l in args] result = [(l / 2.54) for l in [sum(abs_args), *args]] return result @@ -166,12 +251,12 @@ def lin_pattern(*args): # Scale factor to convert various units to meters. UNITS_PER_METER = { - Unit.INCH: 100 / 2.54, - Unit.FOOT: 100 / (12 * 2.54), - Unit.MICRO: 1_000_000, - Unit.MILLIMETER: 1000, - Unit.CENTIMETER: 100, - Unit.METER: 1, + Unit.IN: 100 / 2.54, + Unit.FT: 100 / (12 * 2.54), + Unit.MC: 1_000_000, + Unit.MM: 1000, + Unit.CM: 100, + Unit.M: 1, } @@ -198,76 +283,170 @@ class Export2D(object): DEFAULT_LINE_WEIGHT = 0.09 DEFAULT_LINE_TYPE = LineType.CONTINUOUS - # Pull default (ANSI) linetypes out of ezdxf for more convenient - # lookup and add some ISO linetypes. + # Define the line types. LINETYPE_DEFS = { - name: (desc, pattern) for name, desc, pattern in ezdxf_linetypes() - } | { + LineType.CONTINUOUS.value: ("Solid", [0.0]), + LineType.BORDER.value: ( + "Border __ __ . __ __ . __ __ . __ __ . __ __ .", + ansi_pattern(0.5, -0.25, 0.5, -0.25, 0, -0.25), + ), + LineType.BORDER2.value: ( + "Border (.5x) __.__.__.__.__.__.__.__.__.__.__.", + ansi_pattern(0.25, -0.125, 0.25, -0.125, 0, -0.125), + ), + LineType.BORDERX2.value: ( + "Border (2x) ____ ____ . ____ ____ . ___", + ansi_pattern(1.0, -0.5, 1.0, -0.5, 0, -0.5), + ), + LineType.CENTER.value: ( + "Center ____ _ ____ _ ____ _ ____ _ ____ _ ____", + ansi_pattern(1.25, -0.25, 0.25, -0.25), + ), + LineType.CENTER2.value: ( + "Center (.5x) ___ _ ___ _ ___ _ ___ _ ___ _ ___", + ansi_pattern(0.75, -0.125, 0.125, -0.125), + ), + LineType.CENTERX2.value: ( + "Center (2x) ________ __ ________ __ _____", + ansi_pattern(2.5, -0.5, 0.5, -0.5), + ), + LineType.DASHDOT.value: ( + "Dash dot __ . __ . __ . __ . __ . __ . __ . __", + ansi_pattern(0.5, -0.25, 0, -0.25), + ), + LineType.DASHDOT2.value: ( + "Dash dot (.5x) _._._._._._._._._._._._._._._.", + ansi_pattern(0.25, -0.125, 0, -0.125), + ), + LineType.DASHDOTX2.value: ( + "Dash dot (2x) ____ . ____ . ____ . ___", + ansi_pattern(1.0, -0.5, 0, -0.5), + ), + LineType.DASHED.value: ( + "Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _", + ansi_pattern(0.5, -0.25), + ), + LineType.DASHED2.value: ( + "Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ", + ansi_pattern(0.25, -0.125), + ), + LineType.DASHEDX2.value: ( + "Dashed (2x) ____ ____ ____ ____ ____ ___", + ansi_pattern(1.0, -0.5), + ), + LineType.DIVIDE.value: ( + "Divide ____ . . ____ . . ____ . . ____ . . ____", + ansi_pattern(0.5, -0.25, 0, -0.25, 0, -0.25), + ), + LineType.DIVIDE2.value: ( + "Divide (.5x) __..__..__..__..__..__..__..__.._", + ansi_pattern(0.25, -0.125, 0, -0.125, 0, -0.125), + ), + LineType.DIVIDEX2.value: ( + "Divide (2x) ________ . . ________ . . _", + ansi_pattern(1.0, -0.5, 0, -0.5, 0, -0.5), + ), + LineType.DOT.value: ( + "Dot . . . . . . . . . . . . . . . . . . . . . . . .", + ansi_pattern(0, -0.25), + ), + LineType.DOT2.value: ( + "Dot (.5x) ........................................", + ansi_pattern(0, -0.125), + ), + LineType.DOTX2.value: ( + "Dot (2x) . . . . . . . . . . . . . .", + ansi_pattern(0, -0.5), + ), + LineType.HIDDEN.value: ( + "Hidden __ __ __ __ __ __ __ __ __ __ __ __ __ __", + ansi_pattern(0.25, -0.125), + ), + LineType.HIDDEN2.value: ( + "Hidden (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ", + ansi_pattern(0.125, -0.0625), + ), + LineType.HIDDENX2.value: ( + "Hidden (2x) ____ ____ ____ ____ ____ ____ ____ ", + ansi_pattern(0.5, -0.25), + ), + LineType.PHANTOM.value: ( + "Phantom ______ __ __ ______ __ __ ______ ", + ansi_pattern(1.25, -0.25, 0.25, -0.25, 0.25, -0.25), + ), + LineType.PHANTOM2.value: ( + "Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _", + ansi_pattern(0.625, -0.125, 0.125, -0.125, 0.125, -0.125), + ), + LineType.PHANTOMX2.value: ( + "Phantom (2x) ____________ ____ ____ _", + ansi_pattern(2.5, -0.5, 0.5, -0.5, 0.5, -0.5), + ), LineType.ISO_DASH.value: ( "ISO dash __ __ __ __ __ __ __ __ __ __ __ __ __", - lin_pattern(12, -3), + iso_pattern(12, -3), ), LineType.ISO_DASH_SPACE.value: ( "ISO dash space __ __ __ __ __ __", - lin_pattern(12, -18), + iso_pattern(12, -18), ), LineType.ISO_LONG_DASH_DOT.value: ( "ISO long-dash dot ____ . ____ . ____ . ____ . _", - lin_pattern(24, -3, 0, -3), + iso_pattern(24, -3, 0, -3), ), LineType.ISO_LONG_DASH_DOUBLE_DOT.value: ( "ISO long-dash double-dot ____ .. ____ .. ____ . ", - lin_pattern(24, -3, 0, -3, 0, -3), + iso_pattern(24, -3, 0, -3, 0, -3), ), LineType.ISO_LONG_DASH_TRIPLE_DOT.value: ( "ISO long-dash triple-dot ____ ... ____ ... ____", - lin_pattern(24, -3, 0, -3, 0, -3, 0, -3), + iso_pattern(24, -3, 0, -3, 0, -3, 0, -3), ), LineType.ISO_DOT.value: ( "ISO dot . . . . . . . . . . . . . . . . . . . . ", - lin_pattern(0, -3), + iso_pattern(0, -3), ), LineType.ISO_LONG_DASH_SHORT_DASH.value: ( "ISO long-dash short-dash ____ __ ____ __ ____ _", - lin_pattern(24, -3, 6, -3), + iso_pattern(24, -3, 6, -3), ), LineType.ISO_LONG_DASH_DOUBLE_SHORT_DASH.value: ( "ISO long-dash double-short-dash ____ __ __ ____", - lin_pattern(24, -3, 6, -3, 6, -3), + iso_pattern(24, -3, 6, -3, 6, -3), ), LineType.ISO_DASH_DOT.value: ( "ISO dash dot __ . __ . __ . __ . __ . __ . __ . ", - lin_pattern(12, -3, 0, -3), + iso_pattern(12, -3, 0, -3), ), LineType.ISO_DOUBLE_DASH_DOT.value: ( "ISO double-dash dot __ __ . __ __ . __ __ . __ _", - lin_pattern(12, -3, 12, -3, 0, -3), + iso_pattern(12, -3, 12, -3, 0, -3), ), LineType.ISO_DASH_DOUBLE_DOT.value: ( "ISO dash double-dot __ . . __ . . __ . . __ . . ", - lin_pattern(12, -3, 0, -3, 0, -3), + iso_pattern(12, -3, 0, -3, 0, -3), ), LineType.ISO_DOUBLE_DASH_DOUBLE_DOT.value: ( "ISO double-dash double-dot __ __ . . __ __ . . _", - lin_pattern(12, -3, 12, -3, 0, -3, 0, -3), + iso_pattern(12, -3, 12, -3, 0, -3, 0, -3), ), LineType.ISO_DASH_TRIPLE_DOT.value: ( "ISO dash triple-dot __ . . . __ . . . __ . . . _", - lin_pattern(12, -3, 0, -3, 0, -3, 0, -3), + iso_pattern(12, -3, 0, -3, 0, -3, 0, -3), ), LineType.ISO_DOUBLE_DASH_TRIPLE_DOT.value: ( "ISO double-dash triple-dot __ __ . . . __ __ . .", - lin_pattern(12, -3, 12, -3, 0, -3, 0, -3, 0, -3), + iso_pattern(12, -3, 12, -3, 0, -3, 0, -3, 0, -3), ), } # Scale factor to convert from linetype units (1/10 inch). LTYPE_SCALE = { - Unit.INCH: 0.1, - Unit.FOOT: 0.1 / 12, - Unit.MILLIMETER: 2.54, - Unit.CENTIMETER: 0.254, - Unit.METER: 0.00254, + Unit.IN: 0.1, + Unit.FT: 0.1 / 12, + Unit.MM: 2.54, + Unit.CM: 0.254, + Unit.M: 0.00254, } @@ -276,20 +455,58 @@ class Export2D(object): class ExportDXF(Export2D): - - UNITS_LOOKUP = { - Unit.MICRO: 13, - Unit.MILLIMETER: ezdxf.units.MM, - Unit.CENTIMETER: ezdxf.units.CM, - Unit.METER: ezdxf.units.M, - Unit.INCH: ezdxf.units.IN, - Unit.FOOT: ezdxf.units.FT, + """ + The ExportDXF class provides functionality for exporting 2D shapes to DXF + (Drawing Exchange Format) format. DXF is a widely used file format for + exchanging CAD (Computer-Aided Design) data between different software + applications. + + + Args: + version (str, optional): The DXF version to use for the output file. + Defaults to ezdxf.DXF2013. + unit (Unit, optional): The unit used for the exported DXF. It should be + one of the Unit enums: Unit.MC, Unit.MM, Unit.CM, + Unit.M, Unit.IN, or Unit.FT. Defaults to Unit.MM. + color (Optional[ColorIndex], optional): The default color index for shapes. + It can be specified as a ColorIndex enum or None.. Defaults to None. + line_weight (Optional[float], optional): The default line weight + (stroke width) for shapes, in millimeters. . Defaults to None. + line_type (Optional[LineType], optional): e default line type for shapes. + It should be a LineType enum or None.. Defaults to None. + + + Example: + + .. code-block:: python + + exporter = ExportDXF(unit=Unit.MM, line_weight=0.5) + exporter.add_layer("Layer 1", color=ColorIndex.RED, line_type=LineType.DASHED) + exporter.add_shape(shape_object, layer="Layer 1") + exporter.write("output.dxf") + + Raises: + ValueError: unit not supported + + """ + + # A dictionary that maps Unit enums to their corresponding DXF unit + # constants used by the ezdxf library for conversion. + _UNITS_LOOKUP = { + Unit.MC: 13, + Unit.MM: ezdxf.units.MM, + Unit.CM: ezdxf.units.CM, + Unit.M: ezdxf.units.M, + Unit.IN: ezdxf.units.IN, + Unit.FT: ezdxf.units.FT, } + # A set containing the Unit enums that represent metric units + # (millimeter, centimeter, and meter). METRIC_UNITS = { - Unit.MILLIMETER, - Unit.CENTIMETER, - Unit.METER, + Unit.MM, + Unit.CM, + Unit.M, } # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -297,20 +514,20 @@ class ExportDXF(Export2D): def __init__( self, version: str = ezdxf.DXF2013, - unit: Unit = Unit.MILLIMETER, + unit: Unit = Unit.MM, color: Optional[ColorIndex] = None, line_weight: Optional[float] = None, line_type: Optional[LineType] = None, ): - if unit not in self.UNITS_LOOKUP: + if unit not in self._UNITS_LOOKUP: raise ValueError(f"unit `{unit.name}` not supported.") if unit in ExportDXF.METRIC_UNITS: - self._linetype_scale = Export2D.LTYPE_SCALE[Unit.MILLIMETER] + self._linetype_scale = Export2D.LTYPE_SCALE[Unit.MM] else: self._linetype_scale = 1 self._document = ezdxf.new( dxfversion=version, - units=self.UNITS_LOOKUP[unit], + units=self._UNITS_LOOKUP[unit], setup=False, ) self._modelspace = self._document.modelspace() @@ -333,15 +550,23 @@ def add_layer( line_weight: Optional[float] = None, line_type: Optional[LineType] = None, ) -> Self: - """Create a layer definition + """add_layer + + Adds a new layer to the DXF export with the given properties. - Refer to :ref:`ezdxf layers ` and - :doc:`ezdxf layer tutorial `. + Args: + name (str): The name of the layer definition. Must be unique among all layers. + color (Optional[ColorIndex], optional): The color index for shapes on this layer. + It can be specified as a ColorIndex enum or None. Defaults to None. + line_weight (Optional[float], optional): The line weight (stroke width) for shapes + on this layer, in millimeters. Defaults to None. + line_type (Optional[LineType], optional): The line type for shapes on this layer. + It should be a LineType enum or None. Defaults to None. - :param name: layer definition name - :param color: color index. - :param linetype: ezdxf :doc:`line type ` + Returns: + Self: DXF document with additional layer """ + # ezdxf :doc:`line type `. kwargs = {} @@ -380,13 +605,26 @@ def _linetype(self, line_type: LineType) -> str: # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def add_shape(self, shape: Shape, layer: str = "") -> Self: + def add_shape(self, shape: Union[Shape, Iterable[Shape]], layer: str = "") -> Self: + """add_shape + + Adds a shape to the specified layer. + + Args: + shape (Union[Shape, Iterable[Shape]]): The shape or collection of shapes to be + added. It can be a single Shape object or an iterable of Shape objects. + layer (str, optional): The name of the layer where the shape will be + added. If not specified, the default layer will be used. Defaults to "". + + Returns: + Self: Document with additional shape + """ self._non_planar_point_count = 0 - attributes = {} - if layer: - attributes["layer"] = layer - for edge in shape.edges(): - self._convert_edge(edge, attributes) + if isinstance(shape, Shape): + self._add_single_shape(shape, layer) + else: + for s in shape: + self._add_single_shape(s, layer) if self._non_planar_point_count > 0: print(f"WARNING, exporting non-planar shape to 2D format.") print(" This is probably not what you want.") @@ -395,10 +633,24 @@ def add_shape(self, shape: Shape, layer: str = "") -> Self: ) return self + def _add_single_shape(self, shape: Shape, layer: str = ""): + attributes = {} + if layer: + attributes["layer"] = layer + for edge in shape.edges(): + self._convert_edge(edge, attributes) + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, file_name: str): + """write + Writes the DXF data to the specified file name. + + Args: + file_name (str): The file name (including path) where the DXF data will + be written. + """ # Reset the main CAD viewport of the model space to the # extents of its entities. # TODO: Expose viewport control to the user. @@ -427,6 +679,7 @@ def _convert_point(self, pt: Union[gp_XYZ, gp_Pnt, gp_Vec, Vector]) -> Vec2: # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_line(self, edge: Edge, attribs: dict): + """Converts a Line object into a DXF line entity.""" self._modelspace.add_line( self._convert_point(edge.start_point()), self._convert_point(edge.end_point()), @@ -436,20 +689,21 @@ def _convert_line(self, edge: Edge, attribs: dict): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_circle(self, edge: Edge, attribs: dict): - geom = edge._geom_adaptor() - circle = geom.Circle() + """Converts a Circle object into a DXF circle entity.""" + curve = edge._geom_adaptor() + circle = curve.Circle() center = self._convert_point(circle.Location()) radius = circle.Radius() - if edge.is_closed(): + if curve.IsClosed(): self._modelspace.add_circle(center, radius, attribs) else: x_axis = circle.XAxis().Direction() z_axis = circle.Axis().Direction() - phi = x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis) - u1 = geom.FirstParameter() - u2 = geom.LastParameter() + phi = gp_Dir(1, 0, 0).AngleWithRef(x_axis, gp_Dir(0, 0, 1)) + u1 = curve.FirstParameter() + u2 = curve.LastParameter() if z_axis.Z() > 0: angle1 = math.degrees(phi + u1) angle2 = math.degrees(phi + u2) @@ -463,6 +717,7 @@ def _convert_circle(self, edge: Edge, attribs: dict): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_ellipse(self, edge: Edge, attribs: dict): + """Converts an Ellipse object into a DXF ellipse entity.""" geom = edge._geom_adaptor() ellipse = geom.Ellipse() minor_radius = ellipse.MinorRadius() @@ -488,7 +743,7 @@ def _convert_ellipse(self, edge: Edge, attribs: dict): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_bspline(self, edge: Edge, attribs): - + """Converts a BSpline object into a DXF spline entity.""" # This reduces the B-Spline to degree 3, generally adding # poles and knots to approximate the original. # This also will convert basically any edge into a B-Spline. @@ -535,10 +790,13 @@ def _convert_bspline(self, edge: Edge, attribs): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_other(self, edge: Edge, attribs: dict): + """Converts any other type of Edge object into a DXF entity.""" self._convert_bspline(edge, attribs) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + # A dictionary that maps geometry types (e.g., LINE, CIRCLE, ELLIPSE, BSPLINE) + # to their corresponding conversion methods. _CONVERTER_LOOKUP = { GeomType.LINE.name: _convert_line, GeomType.CIRCLE.name: _convert_circle, @@ -561,44 +819,109 @@ def _convert_edge(self, edge: Edge, attribs: dict): class ExportSVG(Export2D): - """SVG file export functionality.""" - - Converter = Callable[[Edge], ET.Element] + """ExportSVG + + SVG file export functionality. + + The ExportSVG class provides functionality for exporting 2D shapes to SVG + (Scalable Vector Graphics) format. SVG is a widely used vector graphics format + that is supported by web browsers and various graphic editors. + + Args: + unit (Unit, optional): The unit used for the exported SVG. It should be one of + the Unit enums: Unit.MM, Unit.CM, or Unit.IN. Defaults to + Unit.MM. + scale (float, optional): The scaling factor applied to the exported SVG. + Defaults to 1. + margin (float, optional): The margin added around the exported shapes. + Defaults to 0. + fit_to_stroke (bool, optional): A boolean indicating whether the SVG view box + should fit the strokes of the shapes. Defaults to True. + precision (int, optional): The number of decimal places used for rounding + coordinates in the SVG. Defaults to 6. + fill_color (Union[ColorIndex, RGB, None], optional): The default fill color + for shapes. It can be specified as a ColorIndex, an RGB tuple, or None. + Defaults to None. + line_color (Union[ColorIndex, RGB, None], optional): The default line color for + shapes. It can be specified as a ColorIndex or an RGB tuple, or None. + Defaults to Export2D.DEFAULT_COLOR_INDEX. + line_weight (float, optional): The default line weight (stroke width) for + shapes, in millimeters. Defaults to Export2D.DEFAULT_LINE_WEIGHT. + line_type (LineType, optional): The default line type for shapes. It should be + a LineType enum. Defaults to Export2D.DEFAULT_LINE_TYPE. + dot_length (Union[DotLength, float], optional): The width of rendered dots in a + Can be either a DotLength enum or a float value in tenths of an inch. + Defaults to DotLength.INKSCAPE_COMPAT. + + + Example: + + .. code-block:: python + + exporter = ExportSVG(unit=Unit.MM, line_weight=0.5) + exporter.add_layer("Layer 1", fill_color=(255, 0, 0), line_color=(0, 0, 255)) + exporter.add_shape(shape_object, layer="Layer 1") + exporter.write("output.svg") + + Raises: + ValueError: Invalid unit. + + """ + + _Converter = Callable[[Edge], ET.Element] # These are the units which are available in the Unit enum *and* # are valid units in SVG. _UNIT_STRING = { - Unit.MILLIMETER: "mm", - Unit.CENTIMETER: "cm", - Unit.INCH: "in", + Unit.MM: "mm", + Unit.CM: "cm", + Unit.IN: "in", } - class Layer(object): + class _Layer(object): def __init__( self, name: str, - color: ColorIndex, + fill_color: Union[ColorIndex, RGB, None], + line_color: Union[ColorIndex, RGB, None], line_weight: float, line_type: LineType, ): + def color_from_index(ci: ColorIndex) -> RGB: + """The easydxf color indices BLACK and WHITE have the same + value (7), and are both mapped to (255,255,255) by the + aci2rgb() function. We prefer (0,0,0).""" + if ci == ColorIndex.BLACK: + return (0, 0, 0) + else: + return aci2rgb(ci.value) + + if isinstance(fill_color, ColorIndex): + fill_color = color_from_index(fill_color) + if isinstance(line_color, ColorIndex): + line_color = color_from_index(line_color) + self.name = name - self.color = color + self.fill_color = fill_color + self.line_color = line_color self.line_weight = line_weight self.line_type = line_type - self.elements: List[ET.Element] = [] + self.elements: list[ET.Element] = [] # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def __init__( self, - unit: Unit = Unit.MILLIMETER, + unit: Unit = Unit.MM, scale: float = 1, margin: float = 0, fit_to_stroke: bool = True, precision: int = 6, - color: ColorIndex = Export2D.DEFAULT_COLOR_INDEX, + fill_color: Union[ColorIndex, RGB, None] = None, + line_color: Union[ColorIndex, RGB, None] = Export2D.DEFAULT_COLOR_INDEX, line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters line_type: LineType = Export2D.DEFAULT_LINE_TYPE, + dot_length: Union[DotLength, float] = DotLength.INKSCAPE_COMPAT, ): if unit not in ExportSVG._UNIT_STRING: raise ValueError( @@ -610,12 +933,19 @@ def __init__( self.margin = margin self.fit_to_stroke = fit_to_stroke self.precision = precision + self.dot_length = dot_length self._non_planar_point_count = 0 - self._layers: Dict[str, ExportSVG.Layer] = {} + self._layers: dict[str, ExportSVG._Layer] = {} self._bounds: BoundBox = None # Add the default layer. - self.add_layer("", color=color, line_weight=line_weight, line_type=line_type) + self.add_layer( + name="", + fill_color=fill_color, + line_color=line_color, + line_weight=line_weight, + line_type=line_type, + ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -623,17 +953,43 @@ def add_layer( self, name: str, *, - color: ColorIndex = Export2D.DEFAULT_COLOR_INDEX, + fill_color: Union[ColorIndex, RGB, None] = None, + line_color: Union[ColorIndex, RGB, None] = Export2D.DEFAULT_COLOR_INDEX, line_weight: float = Export2D.DEFAULT_LINE_WEIGHT, # in millimeters line_type: LineType = Export2D.DEFAULT_LINE_TYPE, ) -> Self: + """add_layer + + Adds a new layer to the SVG export with the given properties. + + Args: + name (str): The name of the layer. Must be unique among all layers. + fill_color (Union[ColorIndex, RGB, None], optional): The fill color for shapes + on this layer. It can be specified as a ColorIndex, an RGB tuple, or None. + Defaults to None. + line_color (Union[ColorIndex, RGB], optional): The line color for shapes on + this layer. It can be specified as a ColorIndex or an RGB tuple, or None. + Defaults to Export2D.DEFAULT_COLOR_INDEX. + line_weight (float, optional): The line weight (stroke width) for shapes on + this layer, in millimeters. Defaults to Export2D.DEFAULT_LINE_WEIGHT. + line_type (LineType, optional): The line type for shapes on this layer. + It should be a LineType enum. Defaults to Export2D.DEFAULT_LINE_TYPE. + + Raises: + ValueError: Duplicate layer name + ValueError: Unknow linetype + + Returns: + Self: Drawing with an additional layer + """ if name in self._layers: raise ValueError(f"Duplicate layer name '{name}'.") if line_type.value not in Export2D.LINETYPE_DEFS: raise ValueError(f"Unknow linetype `{line_type.value}`.") - layer = ExportSVG.Layer( + layer = ExportSVG._Layer( name=name, - color=color, + fill_color=fill_color, + line_color=line_color, line_weight=line_weight, line_type=line_type, ) @@ -642,14 +998,102 @@ def add_layer( # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def add_shape(self, shape: Shape, layer: str = ""): - self._non_planar_point_count = 0 + def add_shape( + self, + shape: Union[Shape, Iterable[Shape]], + layer: str = "", + reverse_wires: bool = False, + ): + """add_shape + + Adds a shape or a collection of shapes to the specified layer. + + Args: + shape (Union[Shape, Iterable[Shape]]): The shape or collection of shapes to be + added. It can be a single Shape object or an iterable of Shape objects. + layer (str, optional): The name of the layer where the shape(s) will be added. + Defaults to "". + reverse_wires (bool, optional): A boolean indicating whether the wires of the + shape(s) should be in reversed direction. Defaults to False. + + Raises: + ValueError: Undefined layer + """ if layer not in self._layers: raise ValueError(f"Undefined layer: {layer}.") layer = self._layers[layer] + if isinstance(shape, Shape): + self._add_single_shape(shape, layer, reverse_wires) + else: + for s in shape: + self._add_single_shape(s, layer, reverse_wires) + + def _add_single_shape(self, shape: Shape, layer: _Layer, reverse_wires: bool): + self._non_planar_point_count = 0 bb = shape.bounding_box() self._bounds = self._bounds.add(bb) if self._bounds else bb - elements = [self._convert_edge(edge) for edge in shape.edges()] + elements = [] + + # Process Faces. + faces = shape.faces() + # print(f"{len(faces)} faces") + for face in faces: + outer = face.outer_wire() + inner = face.inner_wires() + if 0 == len(inner): + # Faces without inner wires can be processed as bare wires. + # This allows circles and ellipses to be preserved in the + # output as primitives. + face_element = self._wire_element(outer, reverse_wires) + else: + # Faces with inner wires are converted into a single SVG + # path element with the inner and outer wires, so that faces + # with holes will render correctly with a fill color. + face_segments = self._wire_segments(outer, reverse_wires) + for i in inner: + segments = self._wire_segments(i, reverse_wires) + face_segments.extend(segments) + face_path = PT.Path(*face_segments) + face_element = ET.Element("path", {"d": face_path.d()}) + elements.append(face_element) + + # Process Wires that are not part of Faces. + # Each wire is converted to a single SVG element. + # Circles and ellipses are preserved, everything else will + # be output as an SVG path element. + loose_wires = [] + explorer = TopExp_Explorer( + shape.wrapped, + ToFind=TopAbs_ShapeEnum.TopAbs_WIRE, + ToAvoid=TopAbs_ShapeEnum.TopAbs_FACE, + ) + while explorer.More(): + topo_wire = explorer.Current() + loose_wires.append(Wire(topo_wire)) + explorer.Next() + # print(f"{len(loose_wires)} loose wires") + for wire in loose_wires: + elements.append(self._wire_element(wire, reverse_wires)) + + # Process Edges that are not part of Wires. + # Each edge is output as an SVG element. + # Closed circular or elliptical edges are output as + # circle or ellipse primitives. Everything else is output + # as an SVG path element. + loose_edges = [] + explorer = TopExp_Explorer( + shape.wrapped, + ToFind=TopAbs_ShapeEnum.TopAbs_EDGE, + ToAvoid=TopAbs_ShapeEnum.TopAbs_WIRE, + ) + while explorer.More(): + topo_edge = explorer.Current() + loose_edges.append(Edge(topo_edge)) + explorer.Next() + # print(f"{len(loose_edges)} loose edges") + loose_edge_elements = [self._edge_element(edge) for edge in loose_edges] + elements.extend(loose_edge_elements) + layer.elements.extend(elements) if self._non_planar_point_count > 0: print(f"WARNING, exporting non-planar shape to 2D format.") @@ -660,7 +1104,46 @@ def add_shape(self, shape: Shape, layer: str = ""): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def path_point(self, pt: Union[gp_Pnt, Vector]) -> complex: + @staticmethod + def _wire_edges(wire: Wire, reverse: bool) -> List[Edge]: + edges = [] + explorer = BRepTools_WireExplorer(wire.wrapped) + while explorer.More(): + topo_edge = explorer.Current() + edges.append(Edge(topo_edge)) + explorer.Next() + if reverse: + edges.reverse() + return edges + + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + def _wire_segments(self, wire: Wire, reverse: bool) -> list[PathSegment]: + edges = ExportSVG._wire_edges(wire, reverse) + wire_segments: list[PathSegment] = [] + for edge in edges: + edge_segments = self._edge_segments(edge, reverse) + wire_segments.extend(edge_segments) + return wire_segments + + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + def _wire_element(self, wire: Wire, reverse: bool) -> ET.Element: + edges = ExportSVG._wire_edges(wire, reverse) + if len(edges) == 1: + wire_element = self._edge_element(edges[0]) + else: + wire_segments: list[PathSegment] = [] + for edge in edges: + edge_segments = self._edge_segments(edge, reverse) + wire_segments.extend(edge_segments) + wire_path = PT.Path(*wire_segments) + wire_element = ET.Element("path", {"d": wire_path.d()}) + return wire_element + + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + def _path_point(self, pt: Union[gp_Pnt, Vector]) -> complex: """Create a complex point from a gp_Pnt or Vector. We are using complex because that is what svgpathtools wants. This method also checks for points z != 0.""" @@ -679,97 +1162,117 @@ def path_point(self, pt: Union[gp_Pnt, Vector]) -> complex: # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_line(self, edge: Edge) -> ET.Element: - p0 = self.path_point(edge @ 0) - p1 = self.path_point(edge @ 1) + def _line_segment(self, edge: Edge, reverse: bool) -> PT.Line: + curve = edge._geom_adaptor() + fp = curve.FirstParameter() + lp = curve.LastParameter() + (u0, u1) = (lp, fp) if reverse else (fp, lp) + p0 = self._path_point(curve.Value(u0)) + p1 = self._path_point(curve.Value(u1)) + result = PT.Line(p0, p1) + return result + + def _line_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + return [self._line_segment(edge, reverse)] + + def _line_element(self, edge: Edge) -> ET.Element: + """Converts a Line object into an SVG line element.""" + segment = self._line_segment(edge, reverse=False) result = ET.Element( "line", { - "x1": str(p0.real), - "y1": str(p0.imag), - "x2": str(p1.real), - "y2": str(p1.imag), + "x1": str(segment.start.real), + "y1": str(segment.start.imag), + "x2": str(segment.end.real), + "y2": str(segment.end.imag), }, ) return result # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_circle(self, edge: Edge) -> ET.Element: - geom = edge._geom_adaptor() - circle = geom.Circle() + def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + curve = edge._geom_adaptor() + circle = curve.Circle() radius = circle.Radius() - center = circle.Location() + x_axis = circle.XAxis().Direction() + z_axis = circle.Axis().Direction() + fp = curve.FirstParameter() + lp = curve.LastParameter() + du = lp - fp + large_arc = (du < -math.pi) or (du > math.pi) + sweep = (z_axis.Z() > 0) ^ reverse + (u0, u1) = (lp, fp) if reverse else (fp, lp) + start = self._path_point(curve.Value(u0)) + end = self._path_point(curve.Value(u1)) + radius = complex(radius, radius) + rotation = math.degrees(gp_Dir(1, 0, 0).AngleWithRef(x_axis, gp_Dir(0, 0, 1))) + if curve.IsClosed(): + midway = self._path_point(curve.Value((u0 + u1) / 2)) + result = [ + PT.Arc(start, radius, rotation, False, sweep, midway), + PT.Arc(midway, radius, rotation, False, sweep, end), + ] + else: + result = [PT.Arc(start, radius, rotation, large_arc, sweep, end)] + return result + def _circle_element(self, edge: Edge) -> ET.Element: + """Converts a Circle object into an SVG circle element.""" if edge.is_closed(): - c = self.path_point(center) + curve = edge._geom_adaptor() + circle = curve.Circle() + radius = circle.Radius() + center = circle.Location() + c = self._path_point(center) result = ET.Element( "circle", {"cx": str(c.real), "cy": str(c.imag), "r": str(radius)} ) else: - x_axis = circle.XAxis().Direction() - z_axis = circle.Axis().Direction() - phi = x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis) - if z_axis.Z() > 0: - u1 = geom.FirstParameter() - u2 = geom.LastParameter() - sweep = True - else: - u1 = -geom.LastParameter() - u2 = -geom.FirstParameter() - sweep = False - du = u2 - u1 - large_arc = (du < -math.pi) or (du > math.pi) - - start = self.path_point(edge @ 0) - end = self.path_point(edge @ 1) - radius = complex(radius, radius) - rotation = math.degrees(phi) - arc = PT.Arc(start, radius, rotation, large_arc, sweep, end) - path = PT.Path(arc) + arcs = self._circle_segments(edge, reverse=False) + path = PT.Path(*arcs) result = ET.Element("path", {"d": path.d()}) return result # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_ellipse(self, edge: Edge) -> ET.Element: - geom = edge._geom_adaptor() - ellipse = geom.Ellipse() + def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + curve = edge._geom_adaptor() + ellipse = curve.Ellipse() minor_radius = ellipse.MinorRadius() major_radius = ellipse.MajorRadius() x_axis = ellipse.XAxis().Direction() z_axis = ellipse.Axis().Direction() - if z_axis.Z() > 0: - u1 = geom.FirstParameter() - u2 = geom.LastParameter() - sweep = True - else: - u1 = -geom.LastParameter() - u2 = -geom.FirstParameter() - sweep = False - du = u2 - u1 + fp = curve.FirstParameter() + lp = curve.LastParameter() + du = lp - fp large_arc = (du < -math.pi) or (du > math.pi) - - start = self.path_point(edge @ 0) - end = self.path_point(edge @ 1) + sweep = (z_axis.Z() > 0) ^ reverse + (u0, u1) = (lp, fp) if reverse else (fp, lp) + start = self._path_point(curve.Value(u0)) + end = self._path_point(curve.Value(u1)) radius = complex(major_radius, minor_radius) - rotation = math.degrees(x_axis.AngleWithRef(gp_Dir(1, 0, 0), z_axis)) - if edge.is_closed(): - midway = self.path_point(edge @ 0.5) - arcs = [ + rotation = math.degrees(gp_Dir(1, 0, 0).AngleWithRef(x_axis, gp_Dir(0, 0, 1))) + if curve.IsClosed(): + midway = self._path_point(curve.Value((u0 + u1) / 2)) + result = [ PT.Arc(start, radius, rotation, False, sweep, midway), PT.Arc(midway, radius, rotation, False, sweep, end), ] else: - arcs = [PT.Arc(start, radius, rotation, large_arc, sweep, end)] + result = [PT.Arc(start, radius, rotation, large_arc, sweep, end)] + return result + + def _ellipse_element(self, edge: Edge) -> ET.Element: + """Converts an Ellipse object into an SVG ellipse element.""" + arcs = self._ellipse_segments(edge, reverse=False) path = PT.Path(*arcs) result = ET.Element("path", {"d": path.d()}) return result # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_bspline(self, edge: Edge) -> ET.Element: - + def _bspline_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: # This reduces the B-Spline to degree 3, generally adding # poles and knots to approximate the original. # This also will convert basically any edge into a B-Spline. @@ -788,16 +1291,16 @@ def _convert_bspline(self, edge: Edge) -> ET.Element: # describe_bspline(spline) # Convert the B-Spline to Bezier curves. - # From the OCCT 7.6.0 documentation: - # > Note: ParametricTolerance is not used. + # According to the OCCT 7.6.0 documentation, + # "ParametricTolerance is not used." converter = GeomConvert_BSplineCurveToBezierCurve( spline, u1, u2, Export2D.PARAMETRIC_TOLERANCE ) - def make_segment( - bezier: Geom_BezierCurve, - ) -> Union[PT.Line, PT.QuadraticBezier, PT.CubicBezier]: - p = [self.path_point(p) for p in bezier.Poles()] + def make_segment(bezier: Geom_BezierCurve, reverse: bool) -> PathSegment: + p = [self._path_point(p) for p in bezier.Poles()] + if reverse: + p.reverse() if len(p) == 2: result = PT.Line(start=p[0], end=p[1]) elif len(p) == 3: @@ -810,65 +1313,120 @@ def make_segment( raise ValueError(f"Surprising Bézier of degree {bezier.Degree()}!") return result - segments = [ - make_segment(converter.Arc(i)) for i in range(1, converter.NbArcs() + 1) + result = [ + make_segment(converter.Arc(i), reverse) + for i in range(1, converter.NbArcs() + 1) ] + if reverse: + result.reverse() + return result + + def _bspline_element(self, edge: Edge) -> ET.Element: + """Converts a BSpline object into an SVG path element representing a Bézier curve.""" + segments = self._bspline_segments(edge, reverse=False) path = PT.Path(*segments) result = ET.Element("path", {"d": path.d()}) return result # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _convert_other(self, edge: Edge) -> ET.Element: - # _convert_bspline can actually handle basically anything + def _other_segments(self, edge: Edge, reverse: bool): + # _bspline_segments can actually handle basically anything + # because it calls Edge.to_splines() first thing. + return self._bspline_segments(edge, reverse) + + def _other_element(self, edge: Edge) -> ET.Element: + # _bspline_element can actually handle basically anything # because it calls Edge.to_splines() first thing. - return self._convert_bspline(edge) + return self._bspline_element(edge) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - _CONVERTER_LOOKUP = { - GeomType.LINE.name: _convert_line, - GeomType.CIRCLE.name: _convert_circle, - GeomType.ELLIPSE.name: _convert_ellipse, - GeomType.BSPLINE.name: _convert_bspline, + _SEGMENT_LOOKUP = { + GeomType.LINE.name: _line_segments, + GeomType.CIRCLE.name: _circle_segments, + GeomType.ELLIPSE.name: _ellipse_segments, + GeomType.BSPLINE.name: _bspline_segments, } - def _convert_edge(self, edge: Edge) -> ET.Element: + def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED geom_type = edge.geom_type() - if False and geom_type not in self._CONVERTER_LOOKUP: + if False and geom_type not in self._SEGMENT_LOOKUP: + article = "an" if geom_type[0] in "AEIOU" else "a" + print(f"Hey neat, {article} {geom_type}!") + segments = self._SEGMENT_LOOKUP.get(geom_type, ExportSVG._other_segments) + result = segments(self, edge, reverse ^ edge_reversed) + # print(f"{geom_type} {edge.wrapped.Orientation().name} reverse={reverse^edge_reversed} {result}") + return result + + _ELEMENT_LOOKUP = { + GeomType.LINE.name: _line_element, + GeomType.CIRCLE.name: _circle_element, + GeomType.ELLIPSE.name: _ellipse_element, + GeomType.BSPLINE.name: _bspline_element, + } + + def _edge_element(self, edge: Edge) -> ET.Element: + geom_type = edge.geom_type() + if False and geom_type not in self._ELEMENT_LOOKUP: article = "an" if geom_type[0] in "AEIOU" else "a" print(f"Hey neat, {article} {geom_type}!") - convert = self._CONVERTER_LOOKUP.get(geom_type, ExportSVG._convert_other) - result = convert(self, edge) + element = self._ELEMENT_LOOKUP.get(geom_type, ExportSVG._other_element) + result = element(self, edge) return result # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _group_for_layer(self, layer: Layer, attribs: Dict = {}) -> ET.Element: - (r, g, b) = ( - (0, 0, 0) if layer.color == ColorIndex.BLACK else aci2rgb(layer.color.value) - ) - lwscale = unit_conversion_scale(Unit.MILLIMETER, self.unit) + def _stroke_dasharray(self, layer: _Layer): + ltname = layer.line_type.value + _, pattern = Export2D.LINETYPE_DEFS[ltname] + + try: + d = self.dot_length.value + except: + d = self.dot_length + pattern = copy(pattern) + plen = len(pattern) + for i in range(0, plen): + if pattern[i] == 0: + pattern[i] = d + pattern[i - 1] -= d / 2 + pattern[(i + 1) % plen] -= d / 2 + + ltscale = ExportSVG.LTYPE_SCALE[self.unit] * layer.line_weight / self.scale + result = [f"{round(ltscale * abs(e), self.precision)}" for e in pattern[1:]] + return result + + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + def _group_for_layer(self, layer: _Layer, attribs: dict = {}) -> ET.Element: + if layer.fill_color: + (r, g, b) = layer.fill_color + fill = f"rgb({r},{g},{b})" + else: + fill = "none" + if layer.line_color: + (r, g, b) = layer.line_color + stroke = f"rgb({r},{g},{b})" + else: + stroke = "none" + lwscale = unit_conversion_scale(Unit.MM, self.unit) / self.scale stroke_width = layer.line_weight * lwscale result = ET.Element( "g", attribs | { - "stroke": f"rgb({r},{g},{b})", + "fill": fill, + "stroke": stroke, "stroke-width": f"{stroke_width}", - "fill": "none", }, ) if layer.name: result.set("id", layer.name) if layer.line_type is not LineType.CONTINUOUS: - ltname = layer.line_type.value - _, pattern = Export2D.LINETYPE_DEFS[ltname] - ltscale = ExportSVG.LTYPE_SCALE[self.unit] * layer.line_weight - dash_array = [ - f"{round(ltscale * abs(e), self.precision)}" for e in pattern[1:] - ] + dash_array = self._stroke_dasharray(layer) result.set("stroke-dasharray", " ".join(dash_array)) for element in layer.elements: @@ -879,16 +1437,23 @@ def _group_for_layer(self, layer: Layer, attribs: Dict = {}) -> ET.Element: # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def write(self, path: str): + """write + + Writes the SVG data to the specified file path. + Args: + path (str): The file path where the SVG data will be written. + """ bb = self._bounds - margin = self.margin + doc_margin = self.margin if self.fit_to_stroke: max_line_weight = max(l.line_weight for l in self._layers.values()) - margin += max_line_weight / 2 - view_left = round(+bb.min.X - margin, self.precision) - view_top = round(-bb.max.Y - margin, self.precision) - view_width = round(bb.size.X + 2 * margin, self.precision) - view_height = round(bb.size.Y + 2 * margin, self.precision) + doc_margin += max_line_weight / 2 + view_margin = doc_margin / self.scale + view_left = round(+bb.min.X - view_margin, self.precision) + view_top = round(-bb.max.Y - view_margin, self.precision) + view_width = round(bb.size.X + 2 * view_margin, self.precision) + view_height = round(bb.size.Y + 2 * view_margin, self.precision) view_box = [str(f) for f in [view_left, view_top, view_width, view_height]] doc_width = round(view_width * self.scale, self.precision) doc_height = round(view_height * self.scale, self.precision) @@ -914,8 +1479,9 @@ def write(self, path: str): svg.append(container_group) for _, layer in self._layers.items(): - layer_group = self._group_for_layer(layer) - container_group.append(layer_group) + if layer.elements: + layer_group = self._group_for_layer(layer) + container_group.append(layer_group) xml = ET.ElementTree(svg) ET.indent(xml, " ") diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index 231dc9fb..da0f9f68 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -33,19 +33,20 @@ # other pylint warning to temp remove: # too-many-arguments, too-many-locals, too-many-public-methods, # too-many-statements, too-many-instance-attributes, too-many-branches +import copy import logging from math import degrees, pi, radians from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union, overload from OCP.Bnd import Bnd_Box, Bnd_OBB - -# used for getting underlying geometry -- is this equivalent to brep adaptor? from OCP.BRep import BRep_Tool from OCP.BRepBndLib import BRepBndLib from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace from OCP.BRepGProp import BRepGProp, BRepGProp_Face # used for mass calculation from OCP.BRepMesh import BRepMesh_IncrementalMesh -from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf +from OCP.BRepTools import BRepTools +from OCP.Geom import Geom_Line, Geom_Plane +from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf, GeomAPI_IntCS from OCP.gp import ( gp_Ax1, gp_Ax2, @@ -83,22 +84,21 @@ class Vector: """Create a 3-dimensional vector Args: - args: a 3D vector, with x-y-z parts. + x (float): x component + y (float): y component + z (float): z component + vec (Union[Vector, Sequence(float), gp_Vec, gp_Pnt, gp_Dir, gp_XYZ]): vector representations - you can either provide: - * nothing (in which case the null vector is return) - * a gp_Vec - * a vector ( in which case it is copied ) - * a 3-tuple - * a 2-tuple (z assumed to be 0) - * three float values: x, y, and z - * two float values: x,y + Note that if no z value is provided it's assumed to be zero. If no values are provided + the returned Vector has the value of 0, 0, 0. - Returns: + Attributes: + wrapped (gp_Vec): the OCP vector object """ _wrapped: gp_Vec + _dim = 0 @overload def __init__(self, x: float, y: float, z: float): # pragma: no cover @@ -236,7 +236,7 @@ def sub(self, vec: VectorLike) -> Vector: return result def __sub__(self, vec: Vector) -> Vector: - """Mathematical subtraction function""" + """Mathematical subtraction operator -""" return self.sub(vec) def add(self, vec: VectorLike) -> Vector: @@ -251,7 +251,7 @@ def add(self, vec: VectorLike) -> Vector: return result def __add__(self, vec: Vector) -> Vector: - """Mathematical addition function""" + """Mathematical addition operator +""" return self.add(vec) def multiply(self, scale: float) -> Vector: @@ -259,15 +259,15 @@ def multiply(self, scale: float) -> Vector: return Vector(self.wrapped.Multiplied(scale)) def __mul__(self, scale: float) -> Vector: - """Mathematical multiply function""" + """Mathematical multiply operator *""" return self.multiply(scale) def __truediv__(self, denom: float) -> Vector: - """Mathematical division function""" + """Mathematical division operator /""" return self.multiply(1.0 / denom) def __rmul__(self, scale: float) -> Vector: - """Mathematical multiply function""" + """Mathematical multiply operator *""" return self.multiply(scale) def normalized(self) -> Vector: @@ -354,11 +354,11 @@ def project_to_plane(self, plane: Plane) -> Vector: return self - normal * (((self - base).dot(normal)) / normal.length**2) def __neg__(self) -> Vector: - """Flip direction of vector""" + """Flip direction of vector opertor -""" return self * -1 def __abs__(self) -> float: - """Vector length""" + """Vector length operator abs()""" return self.length def __repr__(self) -> str: @@ -370,7 +370,7 @@ def __str__(self) -> str: return "Vector: " + str((self.X, self.Y, self.Z)) def __eq__(self, other: Vector) -> bool: # type: ignore[override] - """Vectors equal""" + """Vectors equal operator ==""" return self.wrapped.IsEqual(other.wrapped, 0.00001, 0.00001) def __copy__(self) -> Vector: @@ -424,6 +424,12 @@ class Axis: Args: origin (VectorLike): start point direction (VectorLike): direction + edge (Edge): origin & direction defined by start of edge + + Attributes: + position (Vector): the global position of the axis origin + direction (Vector): the normalized direction vector + wrapped (gp_Ax1): the OCP axis object """ @classmethod @@ -444,7 +450,46 @@ def Z(cls) -> Axis: """Z Axis""" return Axis((0, 0, 0), (0, 0, 1)) - def __init__(self, origin: VectorLike, direction: VectorLike): + @property + def location(self) -> Location: + """Return self as Location""" + return Location(Plane(origin=self.position, z_dir=self.direction)) + + @overload + def __init__(self, origin: VectorLike, direction: VectorLike): # pragma: no cover + """Axis: point and direction""" + + @overload + def __init__(self, edge: "Edge"): # pragma: no cover + """Axis: start of Edge""" + + def __init__(self, *args, **kwargs): + origin = None + direction = None + if len(args) == 1: + if type(args[0]).__name__ == "Edge": + origin = args[0].position_at(0) + direction = args[0].tangent_at(0) + else: + origin = args[0] + if len(args) == 2: + origin = args[0] + direction = args[1] + + if "origin" in kwargs: + origin = kwargs["origin"] + if "direction" in kwargs: + direction = kwargs["direction"] + if "edge" in kwargs and type(kwargs["edge"]).__name__ == "Edge": + origin = kwargs["edge"].position_at(0) + direction = kwargs["edge"].tangent_at(0) + + try: + origin = Vector(origin) + direction = Vector(direction) + except TypeError as exc: + raise ValueError("Invalid Axis parameters") from exc + self.wrapped = gp_Ax1( Vector(origin).to_pnt(), gp_Dir(*Vector(direction).normalized().to_tuple()) ) @@ -495,10 +540,6 @@ def located(self, new_location: Location): new_gp_ax1 = self.wrapped.Transformed(new_location.wrapped.Transformation()) return Axis.from_occt(new_gp_ax1) - def to_location(self) -> Location: - """Return self as Location""" - return Location(Plane(origin=self.position, z_dir=self.direction)) - def to_plane(self) -> Plane: """Return self as Plane""" return Plane(origin=self.position, z_dir=self.direction) @@ -592,12 +633,19 @@ def reverse(self) -> Axis: return Axis.from_occt(self.wrapped.Reversed()) def __neg__(self) -> Axis: - """Return a copy of self with the direction reversed""" + """Flip direction operator -""" return self.reverse() class BoundBox: - """A BoundingBox for a Shape""" + """A BoundingBox for a Shape + + Attributes: + min (Vector): the position of the bbox corner with minimal values + max (Vector): the position of the bbox corner with maximal values + size (Vector): the size of the box in each dimension + wrapped (Bnd_Box): the OCP bounding box object + """ def __init__(self, bounding_box: Bnd_Box) -> None: self.wrapped: Bnd_Box = bounding_box @@ -719,6 +767,8 @@ def _from_topo_ds( Returns: """ + BRepTools.Clean_s(shape) # Remove mesh which may impact bbox + tolerance = TOL if tolerance is None else tolerance # tol = TOL (by default) bbox = Bnd_Box() bbox_obb = Bnd_OBB() @@ -762,10 +812,13 @@ def is_inside(self, second_box: BoundBox) -> bool: class Color: """ Color object based on OCCT Quantity_ColorRGBA. + + Attributes: + wrapped (Quantity_ColorRGBA): the OCP color object """ @overload - def __init__(self, name: str, alpha: float = 0.0): + def __init__(self, name: str, alpha: float = 1.0): """Color from name `OCCT Color Names @@ -776,7 +829,7 @@ def __init__(self, name: str, alpha: float = 0.0): """ @overload - def __init__(self, red: float, green: float, blue: float, alpha: float = 0.0): + def __init__(self, red: float, green: float, blue: float, alpha: float = 1.0): """Color from RGBA and Alpha values Args: @@ -787,7 +840,7 @@ def __init__(self, red: float, green: float, blue: float, alpha: float = 0.0): """ def __init__(self, *args, **kwargs): - red, green, blue, alpha, name = 1.0, 1.0, 1.0, 0.0, None + red, green, blue, alpha, name = 1.0, 1.0, 1.0, 1.0, None if len(args) >= 1: if isinstance(args[0], str): name = args[0] @@ -802,14 +855,14 @@ def __init__(self, *args, **kwargs): blue = args[2] if len(args) == 4: alpha = args[3] - if kwargs.get("red"): - red = kwargs.get("red") - if kwargs.get("green"): - green = kwargs.get("green") - if kwargs.get("blue"): - blue = kwargs.get("blue") - if kwargs.get("alpha"): - alpha = kwargs.get("alpha") + if "red" in kwargs: + red = kwargs["red"] + if "green" in kwargs: + green = kwargs["green"] + if "blue" in kwargs: + blue = kwargs["blue"] + if "alpha" in kwargs: + alpha = kwargs["alpha"] if name: self.wrapped = Quantity_ColorRGBA() @@ -837,6 +890,10 @@ def __deepcopy__(self, _memo) -> Color: """Return deepcopy of self""" return Color(*self.to_tuple()) + def __str__(self) -> str: + """Generate string""" + return f"Color: {str(self.to_tuple())}" + class Location: """Location in 3D space. Depending on usage can be absolute or relative. @@ -845,9 +902,8 @@ class Location: objects in both relative and absolute manner. It is the preferred type to locate objects in CQ. - Args: - - Returns: + Attributes: + wrapped (TopLoc_Location): the OCP location object """ @@ -1052,17 +1108,42 @@ def __mul__(self, other: Location) -> Location: if hasattr(other, "wrapped") and not isinstance( other.wrapped, TopLoc_Location ): # Shape - return other.moved(self) + result = other.moved(self) elif isinstance(other, (list, tuple)) and all( [isinstance(o, Location) for o in other] ): - return [Location(self.wrapped * loc.wrapped) for loc in other] + result = [Location(self.wrapped * loc.wrapped) for loc in other] else: - return Location(self.wrapped * other.wrapped) + result = Location(self.wrapped * other.wrapped) + return result def __pow__(self, exponent: int) -> Location: return Location(self.wrapped.Powered(exponent)) + def __eq__(self, other: Location) -> bool: + """Compare Locations""" + if not isinstance(other, Location): + raise ValueError("other must be a Location") + quaternion1 = gp_Quaternion() + quaternion1.SetEulerAngles( + gp_EulerSequence.gp_Intrinsic_XYZ, + radians(self.orientation.X), + radians(self.orientation.Y), + radians(self.orientation.Z), + ) + quaternion2 = gp_Quaternion() + quaternion2.SetEulerAngles( + gp_EulerSequence.gp_Intrinsic_XYZ, + radians(other.orientation.X), + radians(other.orientation.Y), + radians(other.orientation.Z), + ) + return self.position == other.position and quaternion1.IsEqual(quaternion2) + + def __neg__(self) -> Location: + """Flip the orientation without changing the position operator -""" + return Location(-Plane(self)) + def to_axis(self) -> Axis: """Convert the location into an Axis""" return Axis.Z.located(self) @@ -1107,7 +1188,14 @@ def __str__(self): class Rotation(Location): - """Subclass of Location used only for object rotation""" + """Subclass of Location used only for object rotation + + Attributes: + about_x (float): rotation in degrees about X axis + about_y (float): rotation in degrees about Y axis + about_z (float): rotation in degrees about Z axis + + """ def __init__(self, about_x: float = 0, about_y: float = 0, about_z: float = 0): self.about_x = about_x @@ -1191,10 +1279,8 @@ class Matrix: A fourth row may be given, but it is expected to be: [0.0, 0.0, 0.0, 1.0] since this is a transform matrix. - Args: - - Returns: - + Attributes: + wrapped (gp_GTrsf): the OCP transformation function """ @overload @@ -1331,12 +1417,12 @@ class Plane: XZ +x +z -y YX +y +x -z ZY +z +y -x - front +x +y +z - back -x +y -z - left +z +y -x - right -z +y +x - top +x -z +y - bottom +x +z -y + front +x +z -y + back -x +z +y + left -y +z -x + right +y +z +x + top +x +y +z + bottom +x -y -z ======= ====== ====== ====== Args: @@ -1347,6 +1433,16 @@ class Plane: z_dir (Union[tuple[float, float, float], Vector], optional): the normal direction for the plane. Defaults to (0, 0, 1). + Attributes: + origin (Vector): global position of local (0,0,0) point + x_dir (Vector): x direction + y_dir (Vector): y direction + z_dir (Vector): z direction + local_coord_system (gp_Ax3): OCP coordinate system + forward_transform (Matrix): forward location transformation matrix + reverse_transform (Matrix): reverse location transformation matrix + wrapped (gp_Pln): the OCP plane object + Raises: ValueError: z_dir must be non null ValueError: x_dir must be non null @@ -1397,37 +1493,37 @@ def ZY(cls) -> Plane: @property def front(cls) -> Plane: """Front Plane""" - return Plane((0, 0, 0), (1, 0, 0), (0, 0, 1)) + return Plane((0, 0, 0), (1, 0, 0), (0, -1, 0)) @classmethod @property def back(cls) -> Plane: """Back Plane""" - return Plane((0, 0, 0), (-1, 0, 0), (0, 0, -1)) + return Plane((0, 0, 0), (-1, 0, 0), (0, 1, 0)) @classmethod @property def left(cls) -> Plane: """Left Plane""" - return Plane((0, 0, 0), (0, 0, 1), (-1, 0, 0)) + return Plane((0, 0, 0), (0, -1, 0), (-1, 0, 0)) @classmethod @property def right(cls) -> Plane: """Right Plane""" - return Plane((0, 0, 0), (0, 0, -1), (1, 0, 0)) + return Plane((0, 0, 0), (0, 1, 0), (1, 0, 0)) @classmethod @property def top(cls) -> Plane: """Top Plane""" - return Plane((0, 0, 0), (1, 0, 0), (0, 1, 0)) + return Plane((0, 0, 0), (1, 0, 0), (0, 0, 1)) @classmethod @property def bottom(cls) -> Plane: """Bottom Plane""" - return Plane((0, 0, 0), (1, 0, 0), (0, -1, 0)) + return Plane((0, 0, 0), (1, 0, 0), (0, 0, -1)) @staticmethod def get_topods_face_normal(face: TopoDS_Face) -> Vector: @@ -1513,6 +1609,10 @@ def optarg(kwargs, name, args, index, default): if arg_plane: self.wrapped = arg_plane elif arg_face: + # Determine if face is planar + surface = BRep_Tool.Surface_s(arg_face.wrapped) + if not isinstance(surface, Geom_Plane): + raise ValueError("Planes can only be created from planar faces") properties = GProp_GProps() BRepGProp.SurfaceProperties_s(arg_face.wrapped, properties) self._origin = Vector(properties.CentreOfMass()) @@ -1599,22 +1699,22 @@ def __deepcopy__(self, _memo) -> Plane: return Plane(gp_Pln(self.wrapped.Position())) def __eq__(self, other: Plane): - """Are planes equal""" + """Are planes equal operator ==""" return all(self._eq_iter(other)) def __ne__(self, other: Plane): - """Are planes not equal""" + """Are planes not equal operator !+""" return not self.__eq__(other) def __neg__(self) -> Plane: - """Reverse z direction of plane""" + """Reverse z direction of plane operator -""" return Plane(self.origin, self.x_dir, -self.z_dir) def __mul__( self, other: Union[Location, "Shape"] ) -> Union[Plane, List[Plane], "Shape"]: if isinstance(other, Location): - return Plane(self.to_location() * other) + return Plane(self.location * other) elif ( # LocationList hasattr(other, "local_locations") and hasattr(other, "location_index") ) or ( # tuple of locations @@ -1623,7 +1723,7 @@ def __mul__( ): return [self * loc for loc in other] elif hasattr(other, "wrapped") and not isinstance(other, Vector): # Shape - return self.to_location() * other + return self.location * other else: raise TypeError( @@ -1654,32 +1754,40 @@ def origin(self, value): self._origin = Vector(value) self._calc_transforms() - def set_origin2d(self, x: float, y: float) -> Plane: - """Set a new origin in the plane itself + def shift_origin(self, locator: Union[Axis, VectorLike, "Vertex"]) -> Plane: + """shift plane origin - Set a new origin in the plane itself. The plane's orientation and - x_dir are unaffected. + Creates a new plane with the origin moved within the plane to the point of intersection + of the axis or at the given Vertex. The plane's x_dir and z_dir are unchanged. Args: - x (float): offset in the x direction - y (float): offset in the y direction + locator (Union[Axis, VectorLike, Vertex]): Either Axis that intersects the new + plane origin or Vertex within Plane. - Returns: - None - - The new coordinates are specified in terms of the current 2D system. - As an example: - - p = Plane.XY - p.set_origin2d(2, 2) - p.set_origin2d(2, 2) + Raises: + ValueError: Vertex isn't within plane + ValueError: Point isn't within plane + ValueError: Axis doesn't intersect plane - results in a plane with its origin at (x, y) = (4, 4) in global - coordinates. Both operations were relative to local coordinates of the - plane. + Returns: + Plane: plane with new ogin """ - self._origin = self.from_local_coords((x, y)) + if type(locator).__name__ == "Vertex": + new_origin = locator.to_tuple() + if not self.contains(new_origin): + raise ValueError(f"{locator} is not located within plane") + elif isinstance(locator, (tuple, Vector)): + new_origin = Vector(locator) + if not self.contains(locator): + raise ValueError(f"{locator} is not located within plane") + elif isinstance(locator, Axis): + new_origin = self.find_intersection(locator) + if new_origin is None: + raise ValueError(f"{locator} doesn't intersect the plane") + else: + raise TypeError(f"Invalid locate type: {type(locator)}") + return Plane(origin=new_origin, x_dir=self.x_dir, z_dir=self.z_dir) def rotated(self, rotation: VectorLike = (0, 0, 0)) -> Plane: """Returns a copy of this plane, rotated about the specified axes @@ -1707,10 +1815,23 @@ def rotated(self, rotation: VectorLike = (0, 0, 0)) -> Plane: transformation = Matrix(gp_GTrsf(trsf_rotation)) # Compute the new plane. - new_xdir = self.x_dir.transform(transformation) + new_x_dir = self.x_dir.transform(transformation) new_z_dir = self.z_dir.transform(transformation) - return Plane(self._origin, new_xdir, new_z_dir) + return Plane(self._origin, new_x_dir, new_z_dir) + + def move(self, loc: Location) -> Plane: + """Change the position & orientation of self by applying a relative location + + Args: + loc (Location): relative change + + Returns: + Plane: relocated plane + """ + self_copy = copy.deepcopy(self) + self_copy.wrapped.Transform(loc.wrapped.Transformation()) + return Plane(self_copy.wrapped) def _calc_transforms(self): """Computes transformation matrices to convert between local and global coordinates.""" @@ -1746,10 +1867,6 @@ def location(self) -> Location: """Return Location representing the origin and z direction""" return Location(self) - def to_location(self) -> Location: - """Return Location representing the origin and z direction""" - return Location(self) - def to_gp_ax2(self) -> gp_Ax2: """Return gp_Ax2 version of the plane""" axis = gp_Ax2() @@ -1824,6 +1941,15 @@ def from_local_coords(self, obj: Union[tuple, Vector, Any, BoundBox]): """ return self._to_from_local_coords(obj, False) + def location_between(self, other: Plane) -> Location: + """Return a location representing the translation from self to other""" + + transformation = gp_Trsf() + transformation.SetTransformation( + self.wrapped.Position(), other.wrapped.Position() + ) + return Location(transformation) + def contains( self, obj: Union[VectorLike, Axis], tolerance: float = TOLERANCE ) -> bool: @@ -1848,3 +1974,18 @@ def contains( else: return_value = self.wrapped.Contains(Vector(obj).to_pnt(), tolerance) return return_value + + def find_intersection(self, axis: Axis) -> Union[Vector, None]: + """Find intersection of axis and plane""" + geom_line = Geom_Line(axis.wrapped) + geom_plane = Geom_Plane(self.local_coord_system) + + intersection_calculator = GeomAPI_IntCS(geom_line, geom_plane) + + if intersection_calculator.IsDone() and intersection_calculator.NbPoints() == 1: + # Get the intersection point + intersection_point = Vector(intersection_calculator.Point(1)) + else: + intersection_point = None + + return intersection_point diff --git a/src/build123d/importers.py b/src/build123d/importers.py index e137600e..81c29787 100644 --- a/src/build123d/importers.py +++ b/src/build123d/importers.py @@ -30,6 +30,8 @@ import os from math import degrees +from typing import Union +from stl.mesh import Mesh from svgpathtools import svg2paths from OCP.TopoDS import TopoDS_Face, TopoDS_Shape from OCP.BRep import BRep_Builder @@ -37,8 +39,26 @@ from OCP.STEPControl import STEPControl_Reader import OCP.IFSelect from OCP.RWStl import RWStl - -from build123d.topology import Compound, Edge, Face, Shape, ShapeList +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeEdge, + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_MakeVertex, + BRepBuilderAPI_MakeWire, + BRepBuilderAPI_Sewing, +) +from OCP.gp import gp_Pnt + +from build123d.topology import ( + Compound, + Edge, + Face, + Shape, + ShapeList, + Shell, + Solid, + downcast, +) def import_brep(file_name: str) -> Shape: @@ -101,7 +121,11 @@ def import_step(file_name: str) -> Compound: def import_stl(file_name: str) -> Face: """import_stl - Extract shape from an STL file and return them as a Face object. + Extract shape from an STL file and return it as a Face reference object. + + Note that importing with this method and creating a reference is very fast while + creating an editable model (with Mesher) may take minutes depending on the size + of the STL file. Args: file_name (str): file path of STL file to import @@ -110,15 +134,14 @@ def import_stl(file_name: str) -> Face: ValueError: Could not import file Returns: - Face: contents of STL file + Face: STL model """ - # Now read and return the shape + # Read and return the shape reader = RWStl.ReadFile_s(file_name) face = TopoDS_Face() - BRep_Builder().MakeFace(face, reader) - - return Face.cast(face) + stl_obj = Face.cast(face) + return stl_obj def import_svg_as_buildline_code(file_name: str) -> tuple[str, str]: @@ -149,7 +172,8 @@ def import_svg_as_buildline_code(file_name: str) -> tuple[str, str]: ], } paths, _path_attributes = svg2paths(file_name) - builder_name = file_name.split(".")[0] + builder_name = os.path.basename(file_name).split(".")[0] + builder_name = builder_name if builder_name.isidentifier() else "builder" buildline_code = [ "from build123d import *", f"with BuildLine() as {builder_name}:", diff --git a/src/build123d/joints.py b/src/build123d/joints.py new file mode 100644 index 00000000..619549dc --- /dev/null +++ b/src/build123d/joints.py @@ -0,0 +1,730 @@ +""" +build123d joints + +name: joints.py +by: Gumyr +date: August 24, 2023 + +desc: + This python module contains all of the Joint derived classes. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +from __future__ import annotations + +from math import inf +from typing import Union, overload + +from build123d.build_common import validate_inputs +from build123d.build_enums import Align +from build123d.build_part import BuildPart +from build123d.geometry import ( + Axis, + Location, + Plane, + Rotation, + RotationLike, + Vector, + VectorLike, +) +from build123d.topology import Compound, Edge, Joint, Solid + + +class RigidJoint(Joint): + """RigidJoint + + A rigid joint fixes two components to one another. + + Args: + label (str): joint label + to_part (Union[Solid, Compound], optional): object to attach joint to + joint_location (Location): global location of joint + + Attributes: + relative_location (Location): joint location relative to bound object + + """ + + @property + def symbol(self) -> Compound: + """A CAD symbol (XYZ indicator) as bound to part""" + size = self.parent.bounding_box().diagonal / 12 + return Compound.make_triad(axes_scale=size).locate( + self.parent.location * self.relative_location + ) + + def __init__( + self, + label: str, + to_part: Union[Solid, Compound] = None, + joint_location: Location = Location(), + ): + context: BuildPart = BuildPart._get_context(self) + validate_inputs(context, self) + if to_part is None: + if context is not None: + to_part = context + else: + raise ValueError("Either specify to_part or place in BuildPart scope") + + self.relative_location = to_part.location.inverse() * joint_location + to_part.joints[label] = self + super().__init__(label, to_part) + + @overload + def connect_to(self, other: BallJoint, *, angles: RotationLike = None): + """Connect RigidJoint and BallJoint""" + + @overload + def connect_to( + self, other: CylindricalJoint, *, position: float = None, angle: float = None + ): + """Connect RigidJoint and CylindricalJoint""" + + @overload + def connect_to(self, other: LinearJoint, *, position: float = None): + """Connect RigidJoint and LinearJoint""" + + @overload + def connect_to(self, other: RevoluteJoint, *, angle: float = None): + """Connect RigidJoint and RevoluteJoint""" + + @overload + def connect_to(self, other: RigidJoint): + """Connect two RigidJoints together""" + + def connect_to(self, other: Joint, **kwargs): + """Connect the RigidJoint to another Joint + + Args: + other (Joint): joint to connect to + angle (float, optional): angle in degrees. Deaults to range min. + angles (RotationLike, optional): angles about axes in degrees. Defaults to + range minimums. + position (float, optional): linear position. Defaults to linear range min. + + """ + return super()._connect_to(other, **kwargs) + + @overload + def relative_to(self, other: BallJoint, *, angles: RotationLike = None): + """RigidJoint relative to BallJoint""" + + @overload + def relative_to( + self, other: CylindricalJoint, *, position: float = None, angle: float = None + ): + """RigidJoint relative to CylindricalJoint""" + + @overload + def relative_to(self, other: LinearJoint, *, position: float = None): + """RigidJoint relative to LinearJoint""" + + @overload + def relative_to(self, other: RevoluteJoint, *, angle: float = None): + """RigidJoint relative to RevoluteJoint""" + + @overload + def relative_to(self, other: RigidJoint): + """Connect two RigidJoints together""" + + def relative_to(self, other: Joint, **kwargs) -> Location: + """Relative location of RigidJoint to another Joint + + Args: + other (RigidJoint): relative to joint + angle (float, optional): angle in degrees. Deaults to range min. + angles (RotationLike, optional): angles about axes in degrees. Defaults to + range minimums. + position (float, optional): linear position. Defaults to linear range min. + + Raises: + TypeError: other must of type BallJoint, CylindricalJoint, LinearJoint, RevoluteJoint, RigidJoint + + """ + if isinstance(other, RigidJoint): + other_location = self.relative_location * other.relative_location.inverse() + elif isinstance(other, RevoluteJoint): + angle = None + if kwargs: + angle = kwargs["angle"] if "angle" in kwargs else angle + other_location = other.relative_to(self, angle=angle).inverse() + elif isinstance(other, LinearJoint): + position = None + if kwargs: + position = kwargs["position"] if "position" in kwargs else position + other_location = other.relative_to(self, position=position).inverse() + elif isinstance(other, CylindricalJoint): + angle, position = None, None + if kwargs: + angle = kwargs["angle"] if "angle" in kwargs else angle + position = kwargs["position"] if "position" in kwargs else position + other_location = other.relative_to( + self, position=position, angle=angle + ).inverse() + elif isinstance(other, BallJoint): + angles = None + if kwargs: + angles = kwargs["angles"] if "angles" in kwargs else angles + other_location = other.relative_to(self, angles=angles).inverse() + else: + raise TypeError( + f"other must one of type BallJoint, CylindricalJoint, LinearJoint, RevoluteJoint, RigidJoint" + f" not {type(other)}" + ) + + return other_location + + +class RevoluteJoint(Joint): + """RevoluteJoint + + Component rotates around axis like a hinge. + + Args: + label (str): joint label + to_part (Union[Solid, Compound], optional): object to attach joint to + axis (Axis): axis of rotation + angle_reference (VectorLike, optional): direction normal to axis defining where + angles will be measured from. Defaults to None. + range (tuple[float, float], optional): (min,max) angle of joint. Defaults to (0, 360). + + Attributes: + angle (float): angle of joint + angle_reference (Vector): reference for angular poitions + angular_range (tuple[float,float]): min and max angular position of joint + relative_axis (Axis): joint axis relative to bound part + + Raises: + ValueError: angle_reference must be normal to axis + """ + + @property + def symbol(self) -> Compound: + """A CAD symbol representing the axis of rotation as bound to part""" + radius = self.parent.bounding_box().diagonal / 30 + + return Compound.make_compound( + [ + Edge.make_line((0, 0, 0), (0, 0, radius * 10)), + Edge.make_circle(radius), + ] + ).move(self.parent.location * self.relative_axis.location) + + def __init__( + self, + label: str, + to_part: Union[Solid, Compound] = None, + axis: Axis = Axis.Z, + angle_reference: VectorLike = None, + angular_range: tuple[float, float] = (0, 360), + ): + context: BuildPart = BuildPart._get_context(self) + validate_inputs(context, self) + if to_part is None: + if context is not None: + to_part = context + else: + raise ValueError("Either specify to_part or place in BuildPart scope") + + self.angular_range = angular_range + if angle_reference: + if not axis.is_normal(Axis((0, 0, 0), angle_reference)): + raise ValueError("angle_reference must be normal to axis") + self.angle_reference = Vector(angle_reference) + else: + self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir + self._angle = None + self.relative_axis = axis.located(to_part.location.inverse()) + to_part.joints[label] = self + super().__init__(label, to_part) + + def connect_to(self, other: RigidJoint, *, angle: float = None): + """Connect RevoluteJoint and RigidJoint + + Args: + other (RigidJoint): relative to joint + angle (float, optional): angle in degrees. Deaults to range min. + + Returns: + TypeError: other must of type RigidJoint + ValueError: angle out of range + """ + return super()._connect_to(other, angle=angle) + + def relative_to( + self, other: RigidJoint, *, angle: float = None + ): # pylint: disable=arguments-differ + """Relative location of RevoluteJoint to RigidJoint + + Args: + other (RigidJoint): relative to joint + angle (float, optional): angle in degrees. Deaults to range min. + + Raises: + TypeError: other must of type RigidJoint + ValueError: angle out of range + """ + if not isinstance(other, RigidJoint): + raise TypeError(f"other must of type RigidJoint not {type(other)}") + + angle = self.angular_range[0] if angle is None else angle + if angle < self.angular_range[0] or angle > self.angular_range[1]: + raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") + self._angle = angle + # Avoid strange rotations when angle is zero by using 360 instead + angle = 360.0 if angle == 0.0 else angle + rotation = Location( + Plane( + origin=(0, 0, 0), + x_dir=self.angle_reference.rotate(Axis.Z, angle), + z_dir=(0, 0, 1), + ) + ) + return ( + self.relative_axis.location * rotation * other.relative_location.inverse() + ) + + +class LinearJoint(Joint): + """LinearJoint + + Component moves along a single axis. + + Args: + label (str): joint label + to_part (Union[Solid, Compound], optional): object to attach joint to + axis (Axis): axis of linear motion + range (tuple[float, float], optional): (min,max) position of joint. + Defaults to (0, inf). + + Attributes: + axis (Axis): joint axis + angle (float): angle of joint + linear_range (tuple[float,float]): min and max positional values + position (float): joint position + relative_axis (Axis): joint axis relative to bound part + + """ + + @property + def symbol(self) -> Compound: + """A CAD symbol of the linear axis positioned relative to_part""" + radius = (self.linear_range[1] - self.linear_range[0]) / 15 + return Compound.make_compound( + [ + Edge.make_line( + (0, 0, self.linear_range[0]), (0, 0, self.linear_range[1]) + ), + Edge.make_circle(radius), + ] + ).move(self.parent.location * self.relative_axis.location) + + def __init__( + self, + label: str, + to_part: Union[Solid, Compound] = None, + axis: Axis = Axis.Z, + linear_range: tuple[float, float] = (0, inf), + ): + context: BuildPart = BuildPart._get_context(self) + validate_inputs(context, self) + if to_part is None: + if context is not None: + to_part = context + else: + raise ValueError("Either specify to_part or place in BuildPart scope") + + self.axis = axis + self.linear_range = linear_range + self.position = None + self.relative_axis = axis.located(to_part.location.inverse()) + self.angle = None + to_part.joints[label]: dict[str, Joint] = self + super().__init__(label, to_part) + + @overload + def connect_to( + self, other: RevoluteJoint, *, position: float = None, angle: float = None + ): + """Connect LinearJoint and RevoluteJoint""" + + @overload + def connect_to(self, other: RigidJoint, *, position: float = None): + """Connect LinearJoint and RigidJoint""" + + def connect_to(self, other: Joint, **kwargs): + """Connect LinearJoint to another Joint + + Args: + other (Joint): joint to connect to + angle (float, optional): angle in degrees. Deaults to range min. + position (float, optional): linear position. Defaults to linear range min. + + Raises: + TypeError: other must be of type RevoluteJoint or RigidJoint + ValueError: position out of range + ValueError: angle out of range + """ + return super()._connect_to(other, **kwargs) + + @overload + def relative_to( + self, other: RigidJoint, *, position: float = None + ): # pylint: disable=arguments-differ + """Relative location of LinearJoint to RigidJoint""" + + @overload + def relative_to( + self, other: RevoluteJoint, *, position: float = None, angle: float = None + ): # pylint: disable=arguments-differ + """Relative location of LinearJoint to RevoluteJoint""" + + def relative_to(self, other, **kwargs): # pylint: disable=arguments-differ + """Relative location of LinearJoint to RevoluteJoint or RigidJoint + + Args: + other (Joint): joint to connect to + angle (float, optional): angle in degrees. Deaults to range min. + position (float, optional): linear position. Defaults to linear range min. + + Raises: + TypeError: other must be of type RevoluteJoint or RigidJoint + ValueError: position out of range + ValueError: angle out of range + """ + + # Parse the input parameters + position, angle = None, None + if kwargs: + position = kwargs["position"] if "position" in kwargs else position + angle = kwargs["angle"] if "angle" in kwargs else angle + + if not isinstance(other, (RigidJoint, RevoluteJoint)): + raise TypeError( + f"other must of type RigidJoint or RevoluteJoint not {type(other)}" + ) + + position = sum(self.linear_range) / 2 if position is None else position + if not self.linear_range[0] <= position <= self.linear_range[1]: + raise ValueError( + f"position ({position}) must in range of {self.linear_range}" + ) + self.position = position + + if isinstance(other, RevoluteJoint): + other: RevoluteJoint + angle = other.angular_range[0] if angle is None else angle + if not other.angular_range[0] <= angle <= other.angular_range[1]: + raise ValueError( + f"angle ({angle}) must in range of {other.angular_range}" + ) + rotation = Location( + Plane( + origin=(0, 0, 0), + x_dir=other.angle_reference.rotate(other.relative_axis, angle), + z_dir=other.relative_axis.direction, + ) + ) + else: + angle = 0.0 + rotation = Location() + self.angle = angle + joint_relative_position = ( + Location( + self.relative_axis.position + self.relative_axis.direction * position, + ) + * rotation + ) + + if isinstance(other, RevoluteJoint): + other_relative_location = Location(other.relative_axis.position) + else: + other_relative_location = other.relative_location + + return joint_relative_position * other_relative_location.inverse() + + +class CylindricalJoint(Joint): + """CylindricalJoint + + Component rotates around and moves along a single axis like a screw. + + Args: + label (str): joint label + to_part (Union[Solid, Compound], optional): object to attach joint to + axis (Axis): axis of rotation and linear motion + angle_reference (VectorLike, optional): direction normal to axis defining where + angles will be measured from. Defaults to None. + linear_range (tuple[float, float], optional): (min,max) position of joint. + Defaults to (0, inf). + angular_range (tuple[float, float], optional): (min,max) angle of joint. + Defaults to (0, 360). + + Attributes: + axis (Axis): joint axis + linear_position (float): linear joint position + rotational_position (float): revolute joint angle in degrees + angle_reference (Vector): reference for angular poitions + angular_range (tuple[float,float]): min and max angular position of joint + linear_range (tuple[float,float]): min and max positional values + relative_axis (Axis): joint axis relative to bound part + position (float): joint position + angle (float): angle of joint + + Raises: + ValueError: angle_reference must be normal to axis + """ + + @property + def symbol(self) -> Compound: + """A CAD symbol representing the cylindrical axis as bound to part""" + radius = (self.linear_range[1] - self.linear_range[0]) / 15 + return Compound.make_compound( + [ + Edge.make_line( + (0, 0, self.linear_range[0]), (0, 0, self.linear_range[1]) + ), + Edge.make_circle(radius), + ] + ).move(self.parent.location * self.relative_axis.location) + + # @property + # def axis_location(self) -> Location: + # """Current global location of joint axis""" + # return self.parent.location * self.relative_axis.location + + def __init__( + self, + label: str, + to_part: Union[Solid, Compound] = None, + axis: Axis = Axis.Z, + angle_reference: VectorLike = None, + linear_range: tuple[float, float] = (0, inf), + angular_range: tuple[float, float] = (0, 360), + ): + context: BuildPart = BuildPart._get_context(self) + validate_inputs(context, self) + if to_part is None: + if context is not None: + to_part = context + else: + raise ValueError("Either specify to_part or place in BuildPart scope") + + self.axis = axis + self.linear_position = None + self.rotational_position = None + if angle_reference: + if not axis.is_normal(Axis((0, 0, 0), angle_reference)): + raise ValueError("angle_reference must be normal to axis") + self.angle_reference = Vector(angle_reference) + else: + self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir + self.angular_range = angular_range + self.linear_range = linear_range + self.relative_axis = axis.located(to_part.location.inverse()) + self.position = None + self.angle = None + to_part.joints[label]: dict[str, Joint] = self + super().__init__(label, to_part) + + def connect_to( + self, other: RigidJoint, *, position: float = None, angle: float = None + ): + """Connect CylindricalJoint and RigidJoint" + + Args: + other (Joint): joint to connect to + position (float, optional): linear position. Defaults to linear range min. + angle (float, optional): angle in degrees. Deaults to range min. + + Raises: + TypeError: other must be of type RigidJoint + ValueError: position out of range + ValueError: angle out of range + """ + return super()._connect_to(other, position=position, angle=angle) + + def relative_to( + self, other: RigidJoint, *, position: float = None, angle: float = None + ): # pylint: disable=arguments-differ + """Relative location of CylindricalJoint to RigidJoint + + Args: + other (Joint): joint to connect to + position (float, optional): linear position. Defaults to linear range min. + angle (float, optional): angle in degrees. Deaults to range min. + + Raises: + TypeError: other must be of type RigidJoint + ValueError: position out of range + ValueError: angle out of range + """ + if not isinstance(other, RigidJoint): + raise TypeError(f"other must of type RigidJoint not {type(other)}") + + position = sum(self.linear_range) / 2 if position is None else position + if not self.linear_range[0] <= position <= self.linear_range[1]: + raise ValueError( + f"position ({position}) must in range of {self.linear_range}" + ) + self.position = position + angle = sum(self.angular_range) / 2 if angle is None else angle + if not self.angular_range[0] <= angle <= self.angular_range[1]: + raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") + self.angle = angle + + joint_relative_position = Location( + self.relative_axis.position + self.relative_axis.direction * position + ) + joint_rotation = Location( + Plane( + origin=(0, 0, 0), + x_dir=self.angle_reference.rotate(self.relative_axis, angle), + z_dir=self.relative_axis.direction, + ) + ) + + return ( + joint_relative_position * joint_rotation * other.relative_location.inverse() + ) + + +class BallJoint(Joint): + """BallJoint + + A component rotates around all 3 axes using a gimbal system (3 nested rotations). + + Args: + label (str): joint label + to_part (Union[Solid, Compound], optional): object to attach joint to + joint_location (Location): global location of joint + angular_range + (tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional): + X, Y, Z angle (min, max) pairs. Defaults to ((0, 360), (0, 360), (0, 360)). + angle_reference (Plane, optional): plane relative to part defining zero degrees of + rotation. Defaults to Plane.XY. + + Attributes: + relative_location (Location): joint location relative to bound part + angular_range + (tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ]): + X, Y, Z angle (min, max) pairs. + angle_reference (Plane): plane relative to part defining zero degrees of + + """ + + @property + def symbol(self) -> Compound: + """A CAD symbol representing joint as bound to part""" + radius = self.parent.bounding_box().diagonal / 30 + circle_x = Edge.make_circle(radius, self.angle_reference) + circle_y = Edge.make_circle(radius, self.angle_reference.rotated((90, 0, 0))) + circle_z = Edge.make_circle(radius, self.angle_reference.rotated((0, 90, 0))) + + return Compound.make_compound( + [ + circle_x, + circle_y, + circle_z, + Compound.make_text( + "X", radius / 5, align=(Align.CENTER, Align.CENTER) + ).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)), + Compound.make_text( + "Y", radius / 5, align=(Align.CENTER, Align.CENTER) + ).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)), + Compound.make_text( + "Z", radius / 5, align=(Align.CENTER, Align.CENTER) + ).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)), + ] + ).move(self.parent.location * self.relative_location) + + def __init__( + self, + label: str, + to_part: Union[Solid, Compound] = None, + joint_location: Location = Location(), + angular_range: tuple[ + tuple[float, float], tuple[float, float], tuple[float, float] + ] = ((0, 360), (0, 360), (0, 360)), + angle_reference: Plane = Plane.XY, + ): + context: BuildPart = BuildPart._get_context(self) + validate_inputs(context, self) + if to_part is None: + if context is not None: + to_part = context + else: + raise ValueError("Either specify to_part or place in BuildPart scope") + + self.relative_location = to_part.location.inverse() * joint_location + to_part.joints[label] = self + self.angular_range = angular_range + self.angle_reference = angle_reference + super().__init__(label, to_part) + + def connect_to(self, other: RigidJoint, *, angles: RotationLike = None): + """Connect BallJoint and RigidJoint + + Args: + other (RigidJoint): joint to connect to + angles (RotationLike, optional): angles about axes in degrees. Defaults to + range minimums. + + Raises: + TypeError: invalid other joint type + ValueError: angles out of range + """ + return super()._connect_to(other, angles=angles) + + def relative_to( + self, other: RigidJoint, *, angles: RotationLike = None + ): # pylint: disable=arguments-differ + """relative_to - BallJoint + + Return the relative location from this joint to the RigidJoint of another object + + Args: + other (RigidJoint): joint to connect to + angles (RotationLike, optional): angles about axes in degrees. Defaults to + range minimums. + + Raises: + TypeError: invalid other joint type + ValueError: angles out of range + """ + + if not isinstance(other, RigidJoint): + raise TypeError(f"other must of type RigidJoint not {type(other)}") + + rotation = ( + Rotation(*[self.angular_range[i][0] for i in [0, 1, 2]]) + if angles is None + else Rotation(*angles) + ) * self.angle_reference.location + + for i, rotations in zip( + [0, 1, 2], + [rotation.orientation.X, rotation.orientation.Y, rotation.orientation.Z], + ): + if not self.angular_range[i][0] <= rotations <= self.angular_range[i][1]: + raise ValueError( + f"angles ({angles}) must in range of {self.angular_range}" + ) + + return self.relative_location * rotation * other.relative_location.inverse() diff --git a/src/build123d/jupyter_tools.py b/src/build123d/jupyter_tools.py index cf98d5ef..45a20bb9 100644 --- a/src/build123d/jupyter_tools.py +++ b/src/build123d/jupyter_tools.py @@ -30,7 +30,7 @@ from vtkmodules.vtkIOXML import vtkXMLPolyDataWriter -from build123d.topology import Shape +# from build123d.topology import Shape DEFAULT_COLOR = [1, 0.8, 0, 1] @@ -181,7 +181,10 @@ def to_vtkpoly_string( - shape: Shape, tolerance: float = 1e-3, angular_tolerance: float = 0.1 + # shape: Shape, tolerance: float = 1e-3, angular_tolerance: float = 0.1 + shape: "Shape", + tolerance: float = 1e-3, + angular_tolerance: float = 0.1, ) -> str: writer = vtkXMLPolyDataWriter() writer.SetWriteToOutputString(True) @@ -194,7 +197,8 @@ def to_vtkpoly_string( def display(shape): payload: List[Dict[str, Any]] = [] - if isinstance(shape, Shape): + # if isinstance(shape, Shape): + if hasattr(shape, "wrapped"): # Is a "Shape" payload.append( dict( shape=to_vtkpoly_string(shape), diff --git a/src/build123d/mesher.py b/src/build123d/mesher.py new file mode 100644 index 00000000..e4bb1272 --- /dev/null +++ b/src/build123d/mesher.py @@ -0,0 +1,531 @@ +""" +build123d exporter/import for 3MF and STL + +name: mesher.py +by: Gumyr +date: Aug 9th 2023 + +desc: + This module provides the Mesher class that implements exporting and importing + both 3MF and STL mesh files. It uses the 3MF Consortium's Lib3MF library + (see https://github.com/3MFConsortium/lib3mf). + + Creating a 3MF object involves constructing a valid 3D model conforming to + the 3MF specification. The resource hierarchy represents the various + components that make up a 3MF object. The main components required to create + a 3MF object are: + + Wrapper: The wrapper is the highest-level component representing the + entire 3MF model. It serves as a container for all other resources and + provides access to the complete 3D model. The wrapper is the starting point + for creating and managing the 3MF model. + + Model: The model is a core component that contains the geometric and + non-geometric resources of the 3D object. It represents the actual 3D + content, including geometry, materials, colors, textures, and other model + information. + + Resources: Within the model, various resources are used to define + different aspects of the 3D object. Some essential resources are: + + a. Mesh: The mesh resource defines the geometry of the 3D object. It + contains a collection of vertices, triangles, and other geometric + information that describes the shape. + + b. Components: Components allow you to define complex structures by + combining multiple meshes together. They are useful for hierarchical + assemblies and instances. + + c. Materials: Materials define the appearance properties of the + surfaces, such as color, texture, or surface finish. + + d. Textures: Textures are images applied to the surfaces of the 3D + object to add detail and realism. + + e. Colors: Colors represent color information used in the 3D model, + which can be applied to vertices or faces. + + Build Items: Build items are the instances of resources used in the 3D + model. They specify the usage of resources within the model. For example, a + build item can refer to a specific mesh, material, and transformation to + represent an instance of an object. + + Metadata: Metadata provides additional information about the model, such + as author, creation date, and custom properties. + + Attachments: Attachments can include additional files or data associated + with the 3MF object, such as texture images or other external resources. + + When creating a 3MF object, you typically start with the wrapper and then + create or import the necessary resources, such as meshes, materials, and + textures, to define the 3D content. You then organize the model using build + items, specifying how the resources are used in the scene. Additionally, you + can add metadata and attachments as needed to complete the 3MF object. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" +# pylint has trouble with the OCP imports +# pylint: disable=no-name-in-module, import-error +import copy +import ctypes +import os +import sys +import uuid +import warnings +from typing import Iterable, Union + +from OCP.BRep import BRep_Tool +from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeFace, + BRepBuilderAPI_MakePolygon, + BRepBuilderAPI_MakeSolid, + BRepBuilderAPI_Sewing, +) +from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.gp import gp_Pnt +import OCP.TopAbs as ta +from OCP.TopLoc import TopLoc_Location + +from py_lib3mf import Lib3MF +from build123d.build_enums import MeshType, Unit +from build123d.geometry import Color, Vector +from build123d.topology import Compound, Shape, Shell, Solid, downcast + + +class Mesher: + """Mesher + + Tool for exporting and importing meshed objects stored in 3MF or STL files. + + Args: + unit (Unit, optional): model units. Defaults to Unit.MM. + """ + + # Translate b3d Units to Lib3MF ModelUnits + _map_b3d_to_3mf_unit = { + Unit.MC: Lib3MF.ModelUnit.MicroMeter, + Unit.MM: Lib3MF.ModelUnit.MilliMeter, + Unit.CM: Lib3MF.ModelUnit.CentiMeter, + Unit.IN: Lib3MF.ModelUnit.Inch, + Unit.FT: Lib3MF.ModelUnit.Foot, + Unit.M: Lib3MF.ModelUnit.Meter, + } + # Translate Lib3MF ModelUnits to b3d Units + _map_3mf_to_b3d_unit = {v: k for k, v in _map_b3d_to_3mf_unit.items()} + + # Translate b3d MeshTypes to 3MF ObjectType + _map_b3d_mesh_type_3mf = { + MeshType.OTHER: Lib3MF.ObjectType.Other, + MeshType.MODEL: Lib3MF.ObjectType.Model, + MeshType.SUPPORT: Lib3MF.ObjectType.Support, + MeshType.SOLIDSUPPORT: Lib3MF.ObjectType.SolidSupport, + } + # Translate 3MF ObjectType to b3d MeshTypess + _map_3mf_to_b3d_mesh_type = {v: k for k, v in _map_b3d_mesh_type_3mf.items()} + + def __init__(self, unit: Unit = Unit.MM): + self.unit = unit + libpath = os.path.dirname(Lib3MF.__file__) + self.wrapper = Lib3MF.Wrapper(os.path.join(libpath, "lib3mf")) + self.model = self.wrapper.CreateModel() + self.model.SetUnit(Mesher._map_b3d_to_3mf_unit[unit]) + self.meshes: list[Lib3MF.MeshObject] = [] + + @property + def model_unit(self) -> Unit: + """Unit used in the model""" + return self.unit + + @property + def triangle_counts(self) -> list[int]: + """Number of triangles in each of the model's meshes""" + return [m.GetTriangleCount() for m in self.meshes] + + @property + def vertex_counts(self) -> list[int]: + """Number of vertices in each of the models's meshes""" + return [m.GetVertexCount() for m in self.meshes] + + @property + def mesh_count(self) -> int: + """Number of meshes in the model""" + mesh_iterator: Lib3MF.MeshObjectIterator = self.model.GetMeshObjects() + return mesh_iterator.Count() + + @property + def library_version(self) -> str: + """3MF Consortium Lib#MF version""" + major, minor, micro = self.wrapper.GetLibraryVersion() + return f"{major}.{minor}.{micro}" + + def add_meta_data( + self, + name_space: str, + name: str, + value: str, + metadata_type: str, + must_preserve: bool, + ): + """add_meta_data + + Add meta data to the models + + Args: + name_space (str): categorizer of different metadata entries + name (str): metadata label + value (str): metadata content + metadata_type (str): metadata trype + must_preserve (bool): metadata must not be removed if unused + """ + # Get an existing meta data group if there is one + mdg = self.model.GetMetaDataGroup() + if mdg is None: + # Create a components object to attach the meta data group + components: Lib3MF.ComponentsObject = self.model.AddComponentsObject() + mdg = components.GetMetaDataGroup() + + # Add the meta data + mdg.AddMetaData(name_space, name, value, metadata_type, must_preserve) + + def add_code_to_metadata(self): + """Add the code calling this method to the 3MF metadata with the custom + name space `build123d`, name equal to the base file name and the type + as `python`""" + caller_file = sys._getframe().f_back.f_code.co_filename + with open(caller_file, mode="r", encoding="utf-8") as code_file: + source_code = code_file.read() # read whole file to a string + + self.add_meta_data( + name_space="build123d", + name=os.path.basename(caller_file), + value=source_code, + metadata_type="python", + must_preserve=False, + ) + + def get_meta_data(self) -> list[dict]: + """Retrieve all of the metadata""" + meta_data_group = self.model.GetMetaDataGroup() + meta_data_contents = [] + for i in range(meta_data_group.GetMetaDataCount()): + meta_data = meta_data_group.GetMetaData(i) + meta_data_dict = {} + meta_data_dict["name_space"] = meta_data.GetNameSpace() + meta_data_dict["name"] = meta_data.GetName() + meta_data_dict["type"] = meta_data.GetType() + meta_data_dict["value"] = meta_data.GetValue() + meta_data_contents.append(meta_data_dict) + return meta_data_contents + + def get_meta_data_by_key(self, name_space: str, name: str) -> dict: + """Retrive the metadata value and type for the provided name space and name""" + meta_data_group = self.model.GetMetaDataGroup() + meta_data_contents = {} + meta_data = meta_data_group.GetMetaDataByKey(name_space, name) + meta_data_contents["type"] = meta_data.GetType() + meta_data_contents["value"] = meta_data.GetValue() + return meta_data_contents + + def get_mesh_properties(self) -> list[dict]: + """Retrieve the properties from all the meshes""" + properties = [] + for mesh in self.meshes: + property_dict = {} + property_dict["name"] = mesh.GetName() + property_dict["part_number"] = mesh.GetPartNumber() + type_3mf = Lib3MF.ObjectType(mesh.GetType()) + property_dict["type"] = Mesher._map_3mf_to_b3d_mesh_type[type_3mf].name + _uuid_valid, uuid_value = mesh.GetUUID() + property_dict["uuid"] = uuid_value # are bad values possible? + properties.append(property_dict) + return properties + + @staticmethod + def _mesh_shape( + ocp_mesh: Shape, + linear_deflection: float, + angular_deflection: float, + ): + """Mesh the shape into vertices and triangles""" + loc = TopLoc_Location() # Face locations + BRepMesh_IncrementalMesh( + theShape=ocp_mesh.wrapped, + theLinDeflection=linear_deflection, + isRelative=True, + theAngDeflection=angular_deflection, + isInParallel=False, + ) + + ocp_mesh_vertices = [] + triangles = [] + offset = 0 + for facet in ocp_mesh.faces(): + # Triangulate the face + poly_triangulation = BRep_Tool.Triangulation_s(facet.wrapped, loc) + trsf = loc.Transformation() + # Store the vertices in the triangulated face + node_count = poly_triangulation.NbNodes() + for i in range(1, node_count + 1): + gp_pnt = poly_triangulation.Node(i).Transformed(trsf) + pnt = (gp_pnt.X(), gp_pnt.Y(), gp_pnt.Z()) + ocp_mesh_vertices.append(pnt) + + # Store the triangles from the triangulated faces + facet_reversed = facet.wrapped.Orientation() == ta.TopAbs_REVERSED + order = [1, 3, 2] if facet_reversed else [1, 2, 3] + for tri in poly_triangulation.Triangles(): + triangles.append([tri.Value(i) + offset - 1 for i in order]) + offset += node_count + return ocp_mesh_vertices, triangles + + @staticmethod + def _create_3mf_mesh( + ocp_mesh_vertices: list[tuple[float, float, float]], + triangles: list[list[int, int, int]], + ): + """Create the data to create a 3mf mesh""" + # Create a lookup table of face vertex to shape vertex + unique_vertices = list(set(ocp_mesh_vertices)) + vert_table = { + i: unique_vertices.index(pnt) for i, pnt in enumerate(ocp_mesh_vertices) + } + + # Create vertex list of 3MF positions + vertices_3mf = [] + gp_pnts = [] + for pnt in unique_vertices: + c_array = (ctypes.c_float * 3)(*pnt) + vertices_3mf.append(Lib3MF.Position(c_array)) + gp_pnts.append(gp_Pnt(*pnt)) + # mesh_3mf.AddVertex Should AddVertex be used to save memory? + + # Create triangle point list + triangles_3mf = [] + for vertex_indices in triangles: + mapped_indices = [ + vert_table[i] for i in [vertex_indices[i] for i in range(3)] + ] + # Remove degenerate triangles + if len(set(mapped_indices)) != 3: + continue + c_array = (ctypes.c_uint * 3)(*mapped_indices) + triangles_3mf.append(Lib3MF.Triangle(c_array)) + + return (vertices_3mf, triangles_3mf) + + def _add_color(self, b3d_shape: Shape, mesh_3mf: Lib3MF.MeshObject): + """Transfer color info from shape to mesh""" + if b3d_shape.color: + color_group = self.model.AddColorGroup() + color_index = color_group.AddColor( + self.wrapper.FloatRGBAToColor(*b3d_shape.color.to_tuple()) + ) + triangle_property = Lib3MF.TriangleProperties() + triangle_property.ResourceID = color_group.GetResourceID() + triangle_property.PropertyIDs[0] = color_index + triangle_property.PropertyIDs[1] = color_index + triangle_property.PropertyIDs[2] = color_index + for i in range(mesh_3mf.GetTriangleCount()): + mesh_3mf.SetTriangleProperties(i, ctypes.pointer(triangle_property)) + + # Object Level Property + mesh_3mf.SetObjectLevelProperty(color_group.GetResourceID(), color_index) + + def add_shape( + self, + shape: Union[Shape, Iterable[Shape]], + linear_deflection: float = 0.001, + angular_deflection: float = 0.1, + mesh_type: MeshType = MeshType.MODEL, + part_number: str = None, + uuid_value: uuid = None, + ): + """add_shape + + Add a shape to the 3MF/STL file. + + Args: + shape (Union[Shape, Iterable[Shape]]): build123d object + linear_deflection (float, optional): mesh control for edges. Defaults to 0.001. + angular_deflection (float, optional): mesh control for non-planar surfaces. + Defaults to 0.1. + mesh_type (MeshType, optional): 3D printing use of mesh. Defaults to MeshType.MODEL. + part_number (str, optional): part #. Defaults to None. + uuid_value (uuid, optional): value from uuid package. Defaults to None. + + Rasises: + RuntimeError: 3mf mesh is invalid + Warning: Degenerate shape skipped + Warning: 3mf mesh is not manifold + """ + shapes = [] + for input_shape in shape if isinstance(shape, Iterable) else [shape]: + if isinstance(input_shape, Compound): + shapes.extend(list(input_shape)) + else: + shapes.append(input_shape) + + for b3d_shape in shapes: + # Create a 3MF mesh object + mesh_3mf: Lib3MF.MeshObject = self.model.AddMeshObject() + + # Mesh the shape + ocp_mesh_vertices, triangles = Mesher._mesh_shape( + copy.deepcopy(b3d_shape), + linear_deflection, + angular_deflection, + ) + + # Skip invalid meshes + if len(ocp_mesh_vertices) < 3 or not triangles: + warnings.warn(f"Degenerate shape {b3d_shape} - skipped") + continue + + # Create 3mf mesh inputs + vertices_3mf, triangles_3mf = Mesher._create_3mf_mesh( + ocp_mesh_vertices, triangles + ) + + # Build the mesh + mesh_3mf.SetGeometry(vertices_3mf, triangles_3mf) + + # Add the mesh properties + mesh_3mf.SetType(Mesher._map_b3d_mesh_type_3mf[mesh_type]) + if b3d_shape.label: + mesh_3mf.SetName(b3d_shape.label) + if part_number: + mesh_3mf.SetPartNumber(part_number) + if uuid_value: + mesh_3mf.SetUUID(str(uuid_value)) + # mesh_3mf.SetAttachmentAsThumbnail + # mesh_3mf.SetPackagePart + + # Add color + self._add_color(b3d_shape, mesh_3mf) + + # Check mesh + if not mesh_3mf.IsValid(): + raise RuntimeError("3mf mesh is invalid") + if not mesh_3mf.IsManifoldAndOriented(): + warnings.warn("3mf mesh is not manifold") + + # Add mesh to model + self.meshes.append(mesh_3mf) + self.model.AddBuildItem(mesh_3mf, self.wrapper.GetIdentityTransform()) + + # Not sure is this is required... + components = self.model.AddComponentsObject() + components.AddComponent(mesh_3mf, self.wrapper.GetIdentityTransform()) + + def _get_shape(self, mesh_3mf: Lib3MF.MeshObject) -> Shape: + """Build build123d object from lib3mf mesh""" + # Extract all the vertices + gp_pnts = [gp_Pnt(*p.Coordinates[0:3]) for p in mesh_3mf.GetVertices()] + + # Extract all the triangle and create a Shell from generated Faces + shell_builder = BRepBuilderAPI_Sewing() + for i in range(mesh_3mf.GetTriangleCount()): + # Extract the vertex indices for this triangle + tri_indices = mesh_3mf.GetTriangle(i).Indices[0:3] + # Convert to a list of gp_Pnt + ocp_vertices = [gp_pnts[tri_indices[i]] for i in range(3)] + # Create the triangular face using the polygon + polygon_builder = BRepBuilderAPI_MakePolygon(*ocp_vertices, Close=True) + face_builder = BRepBuilderAPI_MakeFace(polygon_builder.Wire()) + # Add new Face to Shell + shell_builder.Add(face_builder.Face()) + + # Create the Shell + shell_builder.Perform() + occ_shell = downcast(shell_builder.SewedShape()) + + # Create a solid if manifold + shape_obj = Shell(occ_shell) + if shape_obj.is_manifold: + solid_builder = BRepBuilderAPI_MakeSolid(occ_shell) + shape_obj = Solid(solid_builder.Solid()) + + return shape_obj + + def read(self, file_name: str) -> list[Shape]: + """read + + Args: + file_name (str): file path + + Raises: + ValueError: Unknown file format - must be 3mf or stl + + Returns: + list[Shape]: build123d shapes extracted from mesh file + """ + input_file_format = file_name.split(".")[-1].lower() + if input_file_format not in ["3mf", "stl"]: + raise ValueError(f"Unknown file format {input_file_format}") + reader = self.model.QueryReader(input_file_format) + reader.ReadFromFile(file_name) + self.unit = Mesher._map_3mf_to_b3d_unit[self.model.GetUnit()] + + # Extract 3MF meshes and translate to OCP meshes + mesh_iterator: Lib3MF.MeshObjectIterator = self.model.GetMeshObjects() + self.meshes: list[Lib3MF.MeshObject] + for _i in range(mesh_iterator.Count()): + mesh_iterator.MoveNext() + self.meshes.append(mesh_iterator.GetCurrentMeshObject()) + shapes = [] + for mesh in self.meshes: + shape = self._get_shape(mesh) + shape.label = mesh.GetName() + # Extract color + color_indices = [] + for triangle_property in mesh.GetAllTriangleProperties(): + color_indices.extend( + [ + (triangle_property.ResourceID, triangle_property.PropertyIDs[i]) + for i in range(3) + ] + ) + unique_color_indices = list(set(color_indices)) + try: + color_group = self.model.GetColorGroupByID(unique_color_indices[0][0]) + except: + shapes.append(shape) # There are no colors + continue + if len(unique_color_indices) > 1: + warnings.warn("Warning multiple colors found on mesh - only one used") + color_3mf = color_group.GetColor(unique_color_indices[0][1]) + color = (color_3mf.Red, color_3mf.Green, color_3mf.Blue, color_3mf.Alpha) + color = (c / 255.0 for c in color) + shape.color = Color(*color) + shapes.append(shape) + + return shapes + + def write(self, file_name: str): + """write + + Args: + file_name (str): file path + + Raises: + ValueError: Unknown file format - must be 3mf or stl + """ + output_file_format = file_name.split(".")[-1].lower() + if output_file_format not in ["3mf", "stl"]: + raise ValueError(f"Unknown file format {output_file_format}") + writer = self.model.QueryWriter(output_file_format) + writer.WriteToFile(file_name) diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index f5e84dfb..4b6ee943 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -32,10 +32,10 @@ from typing import Iterable, Union from build123d.build_common import WorkplaneList, validate_inputs -from build123d.build_enums import AngularDirection, LengthMode, Mode +from build123d.build_enums import AngularDirection, GeomType, LengthMode, Mode from build123d.build_line import BuildLine from build123d.geometry import Axis, Plane, Vector, VectorLike -from build123d.topology import Edge, Wire +from build123d.topology import Edge, Face, Wire, Curve class BaseLineObject(Wire): @@ -133,38 +133,16 @@ def __init__( if arc_size > 0 else AngularDirection.CLOCKWISE ) - if abs(arc_size) >= 360: - arc = Edge.make_circle( - radius, - circle_workplane, - start_angle=start_angle, - end_angle=start_angle, - angular_direction=arc_direction, - ) - else: - points = [] - points.append( - Vector(center) - + radius * Vector(cos(radians(start_angle)), sin(radians(start_angle))) - ) - points.append( - Vector(center) - + radius - * Vector( - cos(radians(start_angle + arc_size / 2)), - sin(radians(start_angle + arc_size / 2)), - ) - ) - points.append( - Vector(center) - + radius - * Vector( - cos(radians(start_angle + arc_size)), - sin(radians(start_angle + arc_size)), - ) - ) - points = WorkplaneList.localize(*points) - arc = Edge.make_three_point_arc(*points) + arc_size = (arc_size + 360.0) % 360.0 + end_angle = start_angle + arc_size + start_angle = end_angle if arc_size == 360.0 else start_angle + arc = Edge.make_circle( + radius, + circle_workplane, + start_angle=start_angle, + end_angle=end_angle, + angular_direction=arc_direction, + ) super().__init__(arc, mode=mode) @@ -368,6 +346,103 @@ def __init__( super().__init__(helix, mode=mode) +class FilletPolyline(BaseLineObject): + """Line Object: FilletPolyline + + Add a sequence of straight lines defined by successive points that + are filleted to a given radius. + + Args: + pts (VectorLike): sequence of three or more points + radius (float): radius of filleted corners + close (bool, optional): close by generating an extra Edge. Defaults to False. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + ValueError: Three or more points not provided + ValueError: radius must be positive + """ + + _applies_to = [BuildLine._tag] + + def __init__( + self, + *pts: VectorLike, + radius: float, + close: bool = False, + mode: Mode = Mode.ADD, + ): + context: BuildLine = BuildLine._get_context(self) + validate_inputs(context, self) + + if len(pts) < 3: + raise ValueError("filletpolyline requires three or more pts") + if radius <= 0: + raise ValueError("radius must be positive") + + lines_pts = WorkplaneList.localize(*pts) + + # Create the polyline + new_edges = [ + Edge.make_line(lines_pts[i], lines_pts[i + 1]) + for i in range(len(lines_pts) - 1) + ] + if close and (new_edges[0] @ 0 - new_edges[-1] @ 1).length > 1e-5: + new_edges.append(Edge.make_line(new_edges[-1] @ 1, new_edges[0] @ 0)) + wire_of_lines = Wire.make_wire(new_edges) + + # Create a list of vertices from wire_of_lines in the same order as + # the original points so the resulting fillet edges are ordered + ordered_vertices = [] + for pnts in lines_pts: + distance = { + v: (Vector(pnts) - Vector(*v)).length for v in wire_of_lines.vertices() + } + ordered_vertices.append(sorted(distance.items(), key=lambda x: x[1])[0][0]) + + # Fillet the corners + + # Create a map of vertices to edges containing that vertex + vertex_to_edges = { + v: [e for e in wire_of_lines.edges() if v in e.vertices()] + for v in ordered_vertices + } + + # For each corner vertex create a new fillet Edge + fillets = [] + for vertex, edges in vertex_to_edges.items(): + if len(edges) != 2: + continue + other_vertices = set( + ve for e in edges for ve in e.vertices() if ve != vertex + ) + third_edge = Edge.make_line(*[v.to_tuple() for v in other_vertices]) + fillet_face = Face.make_from_wires( + Wire.make_wire(edges + [third_edge]) + ).fillet_2d(radius, [vertex]) + fillets.append(fillet_face.edges().filter_by(GeomType.CIRCLE)[0]) + + # Create the Edges that join the fillets + if close: + interior_edges = [ + Edge.make_line(fillets[i - 1] @ 1, fillets[i] @ 0) + for i in range(len(fillets)) + ] + end_edges = [] + else: + interior_edges = [ + Edge.make_line(fillets[i] @ 1, f @ 0) for i, f in enumerate(fillets[1:]) + ] + end_edges = [ + Edge.make_line(wire_of_lines @ 0, fillets[0] @ 0), + Edge.make_line(fillets[-1] @ 1, wire_of_lines @ 1), + ] + + new_wire = Wire.make_wire(end_edges + interior_edges + fillets) + + super().__init__(new_wire, mode=mode) + + class JernArc(BaseLineObject): """JernArc @@ -379,6 +454,11 @@ class JernArc(BaseLineObject): radius (float): arc radius arc_size (float): arc size in degrees (negative to change direction) mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Attributes: + start (Vector): start point + end_of_arc (Vector): end point of arc + center_point (Vector): center of arc """ _applies_to = [BuildLine._tag] @@ -410,7 +490,12 @@ def __init__( self.end_of_arc = self.center_point + (start - self.center_point).rotate( Axis(start, jern_workplane.z_dir), arc_size ) - arc = Edge.make_tangent_arc(start, start_tangent, self.end_of_arc) + if abs(arc_size) >= 360: + circle_plane = copy.copy(jern_workplane) + circle_plane.origin = self.center_point + arc = Edge.make_circle(radius, circle_plane) + else: + arc = Edge.make_tangent_arc(start, start_tangent, self.end_of_arc) super().__init__(arc, mode=mode) @@ -445,6 +530,53 @@ def __init__(self, *pts: VectorLike, mode: Mode = Mode.ADD): super().__init__(new_edge, mode=mode) +class IntersectingLine(BaseLineObject): + """Intersecting Line Object: Line + + Add a straight line that intersects another line at a given parameter and angle. + + Args: + start (VectorLike): start point + direction (VectorLike): direction to make line + other (Edge): stop at the intersection of other + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + """ + + _applies_to = [BuildLine._tag] + + def __init__( + self, + start: VectorLike, + direction: VectorLike, + other: Union[Curve, Edge, Wire], + mode: Mode = Mode.ADD, + ): + context: BuildLine = BuildLine._get_context(self) + validate_inputs(context, self) + + start = WorkplaneList.localize(start) + direction = WorkplaneList.localize(direction).normalized() + axis = Axis(start, direction) + if context is None: + polar_workplane = Plane.XY + else: + polar_workplane = copy.copy(WorkplaneList._get_context().workplanes[0]) + + intersection_pnts = [ + i + for edge in other.edges() + for i in edge.intersections(polar_workplane, axis) + ] + if not intersection_pnts: + raise ValueError("No intersections found") + + distances = [(start - p).length for p in intersection_pnts] + length = min(distances) + new_edge = Edge.make_line(start, start + direction * length) + super().__init__(new_edge, mode=mode) + + class PolarLine(BaseLineObject): """Line Object: Polar Line @@ -500,7 +632,7 @@ def __init__( elif length_mode == LengthMode.VERTICAL: length_vector = direction * (length / sin(radians(angle))) - new_edge = Edge.make_line(start, start + WorkplaneList.localize(length_vector)) + new_edge = Edge.make_line(start, start + length_vector) super().__init__(new_edge, mode=mode) @@ -549,6 +681,8 @@ class RadiusArc(BaseLineObject): start_point (VectorLike): start end_point (VectorLike): end radius (float): radius + short_sagitta (bool): If True selects the short sagitta, else the + long sagitta crossing the center. Defaults to True. mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: @@ -562,6 +696,7 @@ def __init__( start_point: VectorLike, end_point: VectorLike, radius: float, + short_sagitta: bool = True, mode: Mode = Mode.ADD, ): context: BuildLine = BuildLine._get_context(self) @@ -571,7 +706,10 @@ def __init__( # Calculate the sagitta from the radius length = end.sub(start).length / 2.0 try: - sagitta = abs(radius) - sqrt(radius**2 - length**2) + if short_sagitta: + sagitta = abs(radius) - sqrt(radius**2 - length**2) + else: + sagitta = -abs(radius) - sqrt(radius**2 - length**2) except ValueError as exception: raise ValueError( "Arc radius is not large enough to reach the end point." diff --git a/src/build123d/objects_part.py b/src/build123d/objects_part.py index 4557cf33..483dc7bd 100644 --- a/src/build123d/objects_part.py +++ b/src/build123d/objects_part.py @@ -54,14 +54,14 @@ class BasePartObject(Part): def __init__( self, - solid: Solid, + part: Union[Part, Solid], rotation: RotationLike = (0, 0, 0), align: Union[Align, tuple[Align, Align, Align]] = None, mode: Mode = Mode.ADD, ): if align is not None: align = tuplify(align, 3) - bbox = solid.bounding_box() + bbox = part.bounding_box() align_offset = [] for i in range(3): if align[i] == Align.MIN: @@ -72,27 +72,41 @@ def __init__( ) elif align[i] == Align.MAX: align_offset.append(-bbox.max.to_tuple()[i]) - solid.move(Location(Vector(*align_offset))) + part.move(Location(Vector(*align_offset))) context: BuildPart = BuildPart._get_context(self, log=False) + rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation + self.rotation = rotate if context is None: - new_solids = [solid] - + new_solids = [part.moved(rotate)] else: - rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation - self.rotation = rotate self.mode = mode if not LocationList._get_context(): raise RuntimeError("No valid context found") new_solids = [ - solid.moved(location * rotate) + part.moved(location * rotate) for location in LocationList._get_context().locations ] if isinstance(context, BuildPart): context._add_to_context(*new_solids, mode=mode) - super().__init__(Compound.make_compound(new_solids).wrapped) + if len(new_solids) > 1: + new_part = Compound.make_compound(new_solids).wrapped + elif isinstance(new_solids[0], Compound): # Don't add extra layers + new_part = new_solids[0].wrapped + else: + new_part = Compound.make_compound(new_solids).wrapped + + super().__init__( + obj=new_part, + # obj=Compound.make_compound(new_solids).wrapped, + label=part.label, + material=part.material, + joints=part.joints, + parent=part.parent, + children=part.children, + ) class Box(BasePartObject): @@ -135,7 +149,7 @@ def __init__( solid = Solid.make_box(length, width, height) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) @@ -188,7 +202,7 @@ def __init__( ) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) @@ -238,7 +252,7 @@ def __init__( Plane((0, 0, -counter_bore_depth)), ) ) - super().__init__(solid=solid, rotation=(0, 0, 0), mode=mode) + super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) class CounterSinkHole(BasePartObject): @@ -290,7 +304,7 @@ def __init__( ), Solid.make_cylinder(counter_sink_radius, self.hole_depth), ) - super().__init__(solid=solid, rotation=(0, 0, 0), mode=mode) + super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) class Cylinder(BasePartObject): @@ -337,7 +351,7 @@ def __init__( angle=arc_size, ) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) @@ -381,7 +395,7 @@ def __init__( radius, self.hole_depth, Plane(origin=hole_start, z_dir=(0, 0, -1)) ) super().__init__( - solid=solid, + part=solid, align=(Align.CENTER, Align.CENTER, Align.CENTER), rotation=(0, 0, 0), mode=mode, @@ -436,7 +450,7 @@ def __init__( angle3=arc_size3, ) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) @@ -492,7 +506,7 @@ def __init__( major_angle=major_angle, ) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) @@ -537,6 +551,9 @@ def __init__( context: BuildPart = BuildPart._get_context(self) validate_inputs(context, self) + if any([value <= 0 for value in [xsize, ysize, zsize]]): + raise ValueError("xsize, ysize & zsize must all be greater than zero") + self.xsize = xsize self.ysize = ysize self.zsize = zsize @@ -548,5 +565,5 @@ def __init__( solid = Solid.make_wedge(xsize, ysize, zsize, xmin, zmin, xmax, zmax) super().__init__( - solid=solid, rotation=rotation, align=tuplify(align, 3), mode=mode + part=solid, rotation=rotation, align=tuplify(align, 3), mode=mode ) diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py index c8046c46..3052f69a 100644 --- a/src/build123d/objects_sketch.py +++ b/src/build123d/objects_sketch.py @@ -33,7 +33,7 @@ from build123d.build_common import LocationList, validate_inputs from build123d.build_enums import Align, FontStyle, Mode from build123d.build_sketch import BuildSketch -from build123d.geometry import Axis, Location, Vector, VectorLike +from build123d.geometry import Axis, Location, Rotation, Vector, VectorLike from build123d.topology import Compound, Edge, Face, ShapeList, Sketch, Wire, tuplify @@ -76,13 +76,13 @@ def __init__( context: BuildSketch = BuildSketch._get_context(self, log=False) if context is None: - new_faces = obj.faces() + new_faces = obj.moved(Rotation(0, 0, rotation)).faces() else: self.rotation = rotation self.mode = mode - obj = obj.move(Location((0, 0, 0), (0, 0, 1), rotation)) + obj = obj.moved(Rotation(0, 0, rotation)) new_faces = [ face.moved(location) @@ -365,9 +365,7 @@ def __init__( self.slot_height = height arc = arc if isinstance(arc, Wire) else Wire.make_wire([arc]) - face = Face.make_from_wires(arc.offset_2d(height / 2)[0]).rotate( - Axis.Z, rotation - ) + face = Face.make_from_wires(arc.offset_2d(height / 2)).rotate(Axis.Z, rotation) super().__init__(face, rotation, None, mode) @@ -412,7 +410,7 @@ def __init__( Edge.make_line(point_v, center_v), Edge.make_line(center_v, center_v - half_line), ] - )[0].offset_2d(height / 2)[0] + )[0].offset_2d(height / 2) ) super().__init__(face, rotation, None, mode) @@ -451,7 +449,7 @@ def __init__( Edge.make_line(Vector(-center_separation / 2, 0, 0), Vector()), Edge.make_line(Vector(), Vector(+center_separation / 2, 0, 0)), ] - ).offset_2d(height / 2)[0] + ).offset_2d(height / 2) ) super().__init__(face, rotation, None, mode) @@ -465,6 +463,8 @@ class SlotOverall(BaseSketchObject): width (float): overall width of the slot height (float): diameter of end circles rotation (float, optional): angles to rotate objects. Defaults to 0. + align (Union[Align, tuple[Align, Align]], optional): align min, center, or max of object. + Defaults to (Align.CENTER, Align.CENTER). mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ @@ -475,6 +475,7 @@ def __init__( width: float, height: float, rotation: float = 0, + align: Union[Align, tuple[Align, Align]] = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): context = BuildSketch._get_context(self) @@ -489,9 +490,9 @@ def __init__( Edge.make_line(Vector(-width / 2 + height / 2, 0, 0), Vector()), Edge.make_line(Vector(), Vector(+width / 2 - height / 2, 0, 0)), ] - ).offset_2d(height / 2)[0] + ).offset_2d(height / 2) ) - super().__init__(face, rotation, None, mode) + super().__init__(face, rotation, align, mode) class Text(BaseSketchObject): diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 2cccbcc7..3a66d93d 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -31,7 +31,7 @@ from typing import Union, Iterable from build123d.build_common import Builder, LocationList, WorkplaneList, validate_inputs -from build123d.build_enums import Keep, Kind, Mode +from build123d.build_enums import Keep, Kind, Mode, Side, Transition from build123d.build_line import BuildLine from build123d.build_part import BuildPart from build123d.build_sketch import BuildSketch @@ -43,18 +43,20 @@ Rotation, RotationLike, Vector, + VectorLike, ) from build123d.objects_part import BasePartObject from build123d.objects_sketch import BaseSketchObject +from build123d.objects_curve import BaseLineObject from build123d.topology import ( Compound, Curve, Edge, Face, - Matrix, + GroupBy, Part, - Plane, Shape, + ShapeList, Sketch, Solid, Vertex, @@ -65,12 +67,13 @@ logger = logging.getLogger("build123d") #:TypeVar("AddType"): Type of objects which can be added to a builder -AddType = Union[Edge, Wire, Face, Solid, Compound] +AddType = Union[Edge, Wire, Face, Solid, Compound, Builder] def add( objects: Union[AddType, Iterable[AddType]], rotation: Union[float, RotationLike] = None, + clean: bool = True, mode: Mode = Mode.ADD, ) -> Compound: """Generic Object: Add Object to Part or Sketch @@ -89,23 +92,25 @@ def add( objects (Union[Edge, Wire, Face, Solid, Compound] or Iterable of): objects to add rotation (Union[float, RotationLike], optional): rotation angle for sketch, rotation about each axis for part. Defaults to None. - mode (Mode, optional): combine mode. Defaults to Mode.ADD. + clean (bool, optional): Remove extraneous internal structure. Defaults to True. + mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ context: Builder = Builder._get_context(None) if context is None: raise RuntimeError("Add must have an active builder context") object_iter = objects if isinstance(objects, Iterable) else [objects] + object_iter = [obj._obj if isinstance(obj, Builder) else obj for obj in object_iter] - validate_inputs(context, None, object_iter) + validate_inputs(context, "add", object_iter) if isinstance(context, BuildPart): if rotation is None: rotation = Rotation(0, 0, 0) - elif isinstance(rotation, float): - raise ValueError("Float values of rotation are not valid for BuildPart") elif isinstance(rotation, tuple): rotation = Rotation(*rotation) + else: + raise ValueError("Invalid rotation value") object_iter = [obj.moved(rotation) for obj in object_iter] new_edges = [obj for obj in object_iter if isinstance(obj, Edge)] @@ -146,7 +151,7 @@ def add( for solid in new_solids for location in LocationList._get_context().locations ] - context._add_to_context(*located_solids, mode=mode) + context._add_to_context(*located_solids, clean=clean, mode=mode) new_objects.extend(located_solids) elif isinstance(context, BuildSketch): @@ -181,7 +186,7 @@ def add( def bounding_box( objects: Union[Shape, Iterable[Shape]] = None, - mode: Mode = Mode.ADD, + mode: Mode = Mode.PRIVATE, ) -> Union[Sketch, Part]: """Generic Operation: Add Bounding Box @@ -210,13 +215,13 @@ def bounding_box( for obj in object_list: if isinstance(obj, Vertex): continue - bounding_box = obj.bounding_box() + bbox = obj.bounding_box() vertices = [ - (bounding_box.min.X, bounding_box.min.Y), - (bounding_box.min.X, bounding_box.max.Y), - (bounding_box.max.X, bounding_box.max.Y), - (bounding_box.max.X, bounding_box.min.Y), - (bounding_box.min.X, bounding_box.min.Y), + (bbox.min.X, bbox.min.Y), + (bbox.min.X, bbox.max.Y), + (bbox.max.X, bbox.max.Y), + (bbox.max.X, bbox.min.Y), + (bbox.min.X, bbox.min.Y), ] new_faces.append( Face.make_from_wires(Wire.make_polygon([Vector(v) for v in vertices])) @@ -229,13 +234,13 @@ def bounding_box( for obj in object_list: if isinstance(obj, Vertex): continue - bounding_box = obj.bounding_box() + bbox = obj.bounding_box() new_objects.append( Solid.make_box( - bounding_box.size.X, - bounding_box.size.Y, - bounding_box.size.Z, - Plane((bounding_box.min.X, bounding_box.min.Y, bounding_box.min.Z)), + bbox.size.X, + bbox.size.Y, + bbox.size.Z, + Plane((bbox.min.X, bbox.min.Y, bbox.min.Z)), ) ) if context is not None: @@ -324,12 +329,13 @@ def chamfer( def fillet( objects: Union[ChamferFilletType, Iterable[ChamferFilletType]], radius: float, -) -> Union[Sketch, Part]: +) -> Union[Sketch, Part, Curve]: """Generic Operation: fillet Applies to 2 and 3 dimensional objects. - Fillet the given sequence of edges or vertices. + Fillet the given sequence of edges or vertices. Note that vertices on + either end of an open line will be automatically skipped. Args: objects (Union[Edge,Vertex] or Iterable of): edges or vertices to fillet @@ -391,6 +397,28 @@ def fillet( context._add_to_context(new_sketch, mode=Mode.REPLACE) return new_sketch + elif target._dim == 1: + target = ( + Wire(target.wrapped) + if isinstance(target, BaseLineObject) + else target.wires()[0] + ) + if not all([isinstance(obj, Vertex) for obj in object_list]): + raise ValueError("1D fillet operation takes only Vertices") + # Remove any end vertices as these can't be filleted + if not target.is_closed(): + object_list = filter( + lambda v: not ( + (Vector(*v.to_tuple()) - target.position_at(0)).length == 0 + or (Vector(*v.to_tuple()) - target.position_at(1)).length == 0 + ), + object_list, + ) + new_wire = target.fillet_2d(radius, object_list) + if context is not None: + context._add_to_context(new_wire, mode=Mode.REPLACE) + return new_wire + #:TypeVar("MirrorType"): Type of objects which can be mirrored MirrorType = Union[Edge, Wire, Face, Compound, Curve, Sketch, Part] @@ -453,6 +481,9 @@ def offset( amount: float = 0, openings: Union[Face, list[Face]] = None, kind: Kind = Kind.ARC, + side: Side = Side.BOTH, + closed: bool = True, + min_edge_length: float = None, mode: Mode = Mode.REPLACE, ) -> Union[Curve, Sketch, Part, Compound]: """Generic Operation: offset @@ -470,6 +501,11 @@ def offset( openings (list[Face], optional), sequence of faces to open in part. Defaults to None. kind (Kind, optional): transition shape. Defaults to Kind.ARC. + side (Side, optional): side to place offset. Defaults to Side.BOTH. + closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT + offset. Defaults to True. + min_edge_length (float, optional): repair degenerate edges generated by offset + by eliminating edges of minimum length in offset wire. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. Raises: @@ -502,22 +538,42 @@ def offset( faces.append(obj) elif isinstance(obj, Edge): edges.append(obj) + elif isinstance(obj, Wire): + edges.extend(obj.edges()) + else: + raise TypeError(f"Unsupported type {type(obj)} for {obj}") new_faces = [] for face in faces: - new_faces.append( - Face.make_from_wires(face.outer_wire().offset_2d(amount, kind=kind)[0]) - ) + outer_wire = face.outer_wire().offset_2d(amount, kind=kind) + if min_edge_length is not None: + outer_wire = outer_wire.fix_degenerate_edges(min_edge_length) + inner_wires = [] + for inner_wire in face.inner_wires(): + offset_wire = inner_wire.offset_2d(-amount, kind=kind) + if min_edge_length is not None: + inner_wires.append(offset_wire.fix_degenerate_edges(min_edge_length)) + else: + inner_wires.append(offset_wire) + new_faces.append(Face.make_from_wires(outer_wire, inner_wires)) if edges: if len(edges) == 1 and edges[0].geom_type() == "LINE": - new_wires = Wire.make_wire( - [ - Edge.make_line(edges[0] @ 0.0, edges[0] @ 0.5), - Edge.make_line(edges[0] @ 0.5, edges[0] @ 1.0), - ] - ).offset_2d(amount) + new_wires = [ + Wire.make_wire( + [ + Edge.make_line(edges[0] @ 0.0, edges[0] @ 0.5), + Edge.make_line(edges[0] @ 0.5, edges[0] @ 1.0), + ] + ).offset_2d(amount, kind=kind, side=side, closed=closed) + ] else: - new_wires = Wire.make_wire(edges).offset_2d(amount, kind=kind) + new_wires = [ + Wire.make_wire(edges).offset_2d( + amount, kind=kind, side=side, closed=closed + ) + ] + if min_edge_length is not None: + new_wires = [w.fix_degenerate_edges(min_edge_length) for w in new_wires] else: new_wires = [] @@ -549,6 +605,160 @@ def offset( return offset_compound +#:TypeVar("ProjectType"): Type of objects which can be projected +ProjectType = Union[Edge, Face, Wire, Vector, Vertex] + + +def project( + objects: Union[ProjectType, Iterable[ProjectType]] = None, + workplane: Plane = None, + target: Union[Solid, Compound, Part] = None, + mode: Mode = Mode.ADD, +) -> Union[Curve, Sketch, Compound, ShapeList[Vector]]: + """Generic Operation: project + + Applies to 0, 1, and 2 dimensional objects. + + Project the given objects or points onto a BuildLine or BuildSketch workplane in + the direction of the normal of that workplane. When projecting onto a + sketch a Face(s) are generated while Edges are generated for BuildLine. + Will only use the first if BuildSketch has multiple active workplanes. + In algebra mode a workplane must be provided and the output is either + a Face, Curve, Sketch, Compound, or ShapeList[Vector]. + + Note that only if mode is not Mode.PRIVATE only Faces can be projected into + BuildSketch and Edge/Wires into BuildLine. + + Args: + objects (Union[Edge, Face, Wire, VectorLike, Vertex] or Iterable of): + objects or points to project + workplane (Plane, optional): screen workplane + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + ValueError: project doesn't accept group_by + ValueError: Either a workplane must be provided or a builder must be active + ValueError: Points and faces can only be projected in PRIVATE mode + ValueError: Edges, wires and points can only be projected in PRIVATE mode + RuntimeError: BuildPart doesn't have a project operation + """ + context: Builder = Builder._get_context("project") + + if isinstance(objects, GroupBy): + raise ValueError("project doesn't accept group_by, did you miss [n]?") + + if not objects and context is None: + raise ValueError("No object to project") + elif not objects and context is not None and isinstance(context, BuildPart): + object_list = context.pending_edges + context.pending_faces + context.pending_edges = [] + context.pending_faces = [] + if len(context.pending_face_planes) > 0: + workplane = context.pending_face_planes[0] + context.pending_face_planes = [] + else: + workplane = context.exit_workplanes[0] + else: + object_list = ( + [*objects] if isinstance(objects, (list, tuple, filter)) else [objects] + ) + + # The size of the object determines the size of the target projection screen + # as the screen is normal to the direction of parallel projection + shape_list = [ + Vertex(*o.to_tuple()) if isinstance(o, Vector) else o for o in object_list + ] + object_size = Compound(children=shape_list).bounding_box().diagonal + + point_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] + point_list = [ + pnt.to_vector() if isinstance(pnt, Vertex) else Vector(pnt) + for pnt in point_list + ] + face_list = [o for o in object_list if isinstance(o, Face)] + line_list = [o for o in object_list if isinstance(o, (Edge, Wire))] + + if workplane is None: + if context is None: + raise ValueError( + "Either a workplane must be provided or a builder must be active" + ) + if isinstance(context, BuildLine): + workplane = context.workplanes[0] + if mode != Mode.PRIVATE and (face_list or point_list): + raise ValueError( + "Points and faces can only be projected in PRIVATE mode" + ) + elif isinstance(context, BuildSketch): + workplane = context.workplanes[0] + if mode != Mode.PRIVATE and (line_list or point_list): + raise ValueError( + "Edges, wires and points can only be projected in PRIVATE mode" + ) + + # BuildLine and BuildSketch are from target to workplane while BuildPart is + # from workplane to target so the projection direction needs to be flipped + projection_flip = 1 + if context is not None and isinstance(context, BuildPart): + if mode != Mode.PRIVATE and point_list: + raise ValueError("Points can only be projected in PRIVATE mode") + if target is None: + target = context._obj + projection_flip = -1 + else: + target = Face.make_rect(3 * object_size, 3 * object_size, plane=workplane) + + # validate_inputs(context, "project", object_list) + validate_inputs(context, "project") + + projected_shapes = [] + obj: Shape + for obj in face_list + line_list: + # obj_to_screen = (workplane.origin - obj.center()).normalized() + obj_to_screen = (target.center() - obj.center()).normalized() + if workplane.to_local_coords(obj_to_screen).Z > 0: + projection_direction = -workplane.z_dir * projection_flip + else: + projection_direction = workplane.z_dir * projection_flip + projection = obj.project_to_shape(target, projection_direction) + if projection: + if isinstance(context, BuildSketch): + projected_shapes.extend( + [workplane.to_local_coords(p) for p in projection] + ) + elif isinstance(context, BuildLine): + projected_shapes.extend(projection) + else: # BuildPart + projected_shapes.append(projection[0]) + + projected_points = [] + for pnt in point_list: + pnt_to_target = (workplane.origin - pnt).normalized() + if workplane.to_local_coords(pnt_to_target).Z > 0: + projection_axis = -Axis(pnt, workplane.z_dir * projection_flip) + else: + projection_axis = Axis(pnt, workplane.z_dir * projection_flip) + projection = workplane.to_local_coords( + workplane.find_intersection(projection_axis) + ) + if projection is not None: + projected_points.append(projection) + + if context is not None: + context._add_to_context(*projected_shapes, mode=mode) + + if projected_points: + result = ShapeList(projected_points) + else: + result = Compound.make_compound(projected_shapes) + if all([obj._dim == 2 for obj in object_list]): + result = Sketch(result.wrapped) + elif all([obj._dim == 1 for obj in object_list]): + result = Curve(result.wrapped) + + return result + + def scale( objects: Union[Shape, Iterable[Shape]] = None, by: Union[float, tuple[float, float, float]] = 1, @@ -652,19 +862,6 @@ def split( Raises: ValueError: missing objects """ - - def build_cutter(keep: Keep, max_size: float) -> Solid: - cutter_center = ( - Vector(-max_size, -max_size, 0) - if keep == Keep.TOP - else Vector(-max_size, -max_size, -2 * max_size) - ) - return bisect_by.from_local_coords( - Solid.make_box(2 * max_size, 2 * max_size, 2 * max_size).locate( - Location(cutter_center) - ) - ) - context: Builder = Builder._get_context("split") if objects is None: @@ -679,15 +876,7 @@ def build_cutter(keep: Keep, max_size: float) -> Solid: new_objects = [] for obj in object_list: - max_size = obj.bounding_box().diagonal - - cutters = [] - if keep == Keep.BOTH: - cutters.append(build_cutter(Keep.TOP, max_size)) - cutters.append(build_cutter(Keep.BOTTOM, max_size)) - else: - cutters.append(build_cutter(keep, max_size)) - new_objects.append(obj.intersect(*cutters)) + new_objects.append(obj.split(bisect_by, keep)) if context is not None: context._add_to_context(*new_objects, mode=mode) @@ -701,3 +890,125 @@ def build_cutter(keep: Keep, max_size: float) -> Solid: return Curve(split_compound.wrapped) else: return split_compound + + +#:TypeVar("SweepType"): Type of objects which can be swept +SweepType = Union[Compound, Edge, Wire, Face, Solid] + + +def sweep( + sections: Union[SweepType, Iterable[SweepType]] = None, + path: Union[Curve, Edge, Wire, Iterable[Edge]] = None, + multisection: bool = False, + is_frenet: bool = False, + transition: Transition = Transition.TRANSFORMED, + normal: VectorLike = None, + binormal: Union[Edge, Wire] = None, + clean: bool = True, + mode: Mode = Mode.ADD, +) -> Union[Part, Sketch]: + """Generic Operation: sweep + + Sweep pending 1D or 2D objects along path. + + Args: + sections (Union[Compound, Edge, Wire, Face, Solid]): cross sections to sweep into object + path (Union[Curve, Edge, Wire], optional): path to follow. + Defaults to context pending_edges. + multisection (bool, optional): sweep multiple on path. Defaults to False. + is_frenet (bool, optional): use frenet algorithm. Defaults to False. + transition (Transition, optional): discontinuity handling option. + Defaults to Transition.RIGHT. + normal (VectorLike, optional): fixed normal. Defaults to None. + binormal (Union[Edge, Wire], optional): guide rotation along path. Defaults to None. + clean (bool, optional): Remove extraneous internal structure. Defaults to True. + mode (Mode, optional): combination. Defaults to Mode.ADD. + """ + context: Builder = Builder._get_context("sweep") + + section_list = ( + [*sections] if isinstance(sections, (list, tuple, filter)) else [sections] + ) + section_list = [sec for sec in section_list if sec is not None] + + validate_inputs(context, "sweep", section_list) + + if path is None: + if context is None or context is not None and not context.pending_edges: + raise ValueError("path must be provided") + path_wire = Wire.make_wire(context.pending_edges) + context.pending_edges = [] + else: + if isinstance(path, Iterable): + try: + path_wire = Wire.make_wire(path) + except ValueError as err: + raise ValueError("Unable to build path from edges") from err + else: + path_wire = ( + Wire.make_wire(path.edges()) if not isinstance(path, Wire) else path + ) + + if not section_list: + if ( + context is not None + and isinstance(context, BuildPart) + and context.pending_faces + ): + section_list = context.pending_faces + context.pending_faces = [] + context.pending_face_planes = [] + else: + raise ValueError("No sections provided") + + edge_list = [] + face_list = [] + for sec in section_list: + if isinstance(sec, (Curve, Wire, Edge)): + edge_list.extend(sec.edges()) + else: + face_list.extend(sec.faces()) + + # sweep to create solids + new_solids = [] + if face_list: + if binormal is None and normal is not None: + binormal_mode = Vector(normal) + elif isinstance(binormal, Edge): + binormal_mode = Wire.make_wire([binormal]) + else: + binormal_mode = binormal + if multisection: + sections = [face.outer_wire() for face in face_list] + new_solid = Solid.sweep_multi( + sections, path_wire, True, is_frenet, binormal_mode + ) + else: + for face in face_list: + new_solid = Solid.sweep( + section=face, + path=path_wire, + make_solid=True, + is_frenet=is_frenet, + mode=binormal_mode, + transition=transition, + ) + new_solids.append(new_solid) + + # sweep to create faces + new_faces = [] + if edge_list: + for sec in section_list: + swept = Face.sweep(sec, path_wire) # Could generate a shell here + new_faces.extend(swept.faces()) + + if context is not None: + context._add_to_context(*(new_solids + new_faces), clean=clean, mode=mode) + elif clean: + new_solids = [solid.clean() for solid in new_solids] + new_faces = [face.clean() for face in new_faces] + + if new_solids: + return Part(Compound.make_compound(new_solids).wrapped) + else: + return Sketch(Compound.make_compound(new_faces).wrapped) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index bd9e24bd..54b28be3 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -27,17 +27,12 @@ """ from __future__ import annotations from typing import Union, Iterable -from build123d.build_enums import Mode, Until, Transition +from build123d.build_enums import Mode, Until, Kind, Side from build123d.build_part import BuildPart -from build123d.geometry import ( - Axis, - Location, - Plane, - Vector, - VectorLike, -) +from build123d.geometry import Axis, Plane, Vector, VectorLike from build123d.topology import ( Compound, + Curve, Edge, Face, Shell, @@ -45,15 +40,11 @@ Wire, Part, Sketch, - Shape, + ShapeList, + Vertex, ) -from build123d.build_common import ( - logger, - LocationList, - WorkplaneList, - validate_inputs, -) +from build123d.build_common import logger, WorkplaneList, validate_inputs def extrude( @@ -61,7 +52,7 @@ def extrude( amount: float = None, dir: VectorLike = None, until: Until = None, - target: Shape = None, + target: Union[Compound, Solid] = None, both: bool = False, taper: float = 0.0, clean: bool = True, @@ -110,7 +101,14 @@ def extrude( if isinstance(to_extrude, (tuple, list, filter)) else to_extrude.faces() ) - face_planes = [Plane(face) for face in to_extrude_faces] + face_planes = [] + for face in to_extrude_faces: + try: + plane = Plane(face) + except ValueError: # non-planar face + plane = None + if plane is not None: + face_planes.append(plane) new_solids: list[Solid] = [] @@ -120,6 +118,8 @@ def extrude( Plane(face.center(), face.center_location.x_axis.direction, Vector(dir)) for face in to_extrude_faces ] + if len(face_planes) != len(to_extrude_faces): + raise ValueError("dir must be provided when extruding non-planar faces") if until is not None: if target is None and context is None: @@ -136,13 +136,22 @@ def extrude( for face, plane in zip(to_extrude_faces, face_planes): for direction in [1, -1] if both else [1]: if amount: - new_solids.append( - Solid.extrude_linear( - section=face, - normal=plane.z_dir * amount * direction, - taper=taper, + if taper == 0: + new_solids.append( + Solid.extrude( + face, + direction=plane.z_dir * amount * direction, + ) ) - ) + else: + new_solids.append( + Solid.extrude_taper( + face, + direction=plane.z_dir * amount * direction, + taper=taper, + ) + ) + else: new_solids.append( Solid.extrude_until( @@ -155,8 +164,11 @@ def extrude( if context is not None: context._add_to_context(*new_solids, clean=clean, mode=mode) - elif clean: - new_solids = [solid.clean() for solid in new_solids] + else: + if len(new_solids) > 1: + new_solids = [new_solids.pop().fuse(*new_solids)] + if clean: + new_solids = [solid.clean() for solid in new_solids] return Part(Compound.make_compound(new_solids).wrapped) @@ -213,6 +225,171 @@ def loft( return Part(Compound.make_compound([new_solid]).wrapped) +def make_brake_formed( + thickness: float, + station_widths: Union[float, Iterable[float]], + line: Union[Edge, Wire, Curve] = None, + side: Side = Side.LEFT, + kind: Kind = Kind.ARC, + clean: bool = True, + mode: Mode = Mode.ADD, +) -> Part: + """make_brake_formed + + Create a part typically formed with a sheet metal brake from a single outline. + The line parameter describes how the material is to be bent. Either a single + width value or a width value at each vertex or station is provided to control + the width of the end part. Note that if multiple values are provided there + must be one for each vertex and that the resulting part is composed of linear + segments. + + Args: + thickness (float): sheet metal thickness + station_widths (Union[float, Iterable[float]]): width of part at + each vertex or a single value. Note that this width is perpendicular + to the provided line/plane. + line (Union[Edge, Wire, Curve], optional): outline of part. Defaults to None. + side (Side, optional): offset direction. Defaults to Side.LEFT. + kind (Kind, optional): offset intersection type. Defaults to Kind.ARC. + clean (bool, optional): clean the resulting solid. Defaults to True. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + ValueError: invalid line type + ValueError: not line provided + ValueError: line not suitable + ValueError: incorrect # of width values + + Returns: + Part: sheet metal part + """ + context: BuildPart = BuildPart._get_context("make_brake_formed") + validate_inputs(context, "make_brake_formed") + + if line is not None: + if isinstance(line, Curve): + line = line.wires()[0] + elif not isinstance(line, (Edge, Wire)): + raise ValueError("line must be either a Curve, Edge or Wire") + elif context is not None and not context.pending_edges_as_wire is None: + line = context.pending_edges_as_wire + else: + raise ValueError("A line must be provided") + + # Create the offset + offset_line = line.offset_2d(distance=thickness, kind=kind, side=side, closed=True) + offset_vertices = offset_line.vertices() + + try: + plane = Plane(Face.make_from_wires(offset_line)) + except: + raise ValueError("line not suitable - probably straight") + + # Make edge pairs + station_edges = ShapeList() + line_vertices = line.vertices() + for vertex in line_vertices: + others = offset_vertices.sort_by_distance(Vector(vertex.X, vertex.Y, vertex.Z)) + for other in others[1:]: + if abs(Vector(*(vertex - other).to_tuple()).length - thickness) < 1e-2: + station_edges.append( + Edge.make_line(vertex.to_tuple(), other.to_tuple()) + ) + break + station_edges = station_edges.sort_by(line) + + if isinstance(station_widths, (float, int)): + station_widths = [station_widths] * len(line_vertices) + if len(station_widths) != len(line_vertices): + raise ValueError( + f"widths must either be a single number or an iterable with " + f"a length of the # vertices in line ({len(line_vertices)})" + ) + station_faces = [ + Face.extrude(obj=e, direction=plane.z_dir * w) + for e, w in zip(station_edges, station_widths) + ] + sweep_paths = line.edges().sort_by(line) + sections = [] + for i in range(len(station_faces) - 1): + sections.append( + Solid.sweep_multi( + [station_faces[i], station_faces[i + 1]], path=sweep_paths[i] + ) + ) + if len(sections) > 1: + new_solid = sections.pop().fuse(*sections) + else: + new_solid = sections[0] + + if context is not None: + context._add_to_context(new_solid, clean=clean, mode=mode) + elif clean: + new_solid = new_solid.clean() + + return Part(Compound.make_compound([new_solid]).wrapped) + + +def project_workplane( + origin: Union[VectorLike, Vertex], + x_dir: Union[VectorLike, Vertex], + projection_dir: VectorLike, + distance: float, +) -> Plane: + """Part Operation: project_workplane + + Return a plane to be used as a BuildSketch or BuildLine workplane + with a known origin and x direction. The plane's origin will be + the projection of the provided origin (in 3D space). The plane's + x direction will be the projection of the provided x_dir (in 3D space). + + Args: + origin (Union[VectorLike, Vertex]): origin in 3D space + x_dir (Union[VectorLike, Vertex]): x direction in 3D space + projection_dir (VectorLike): projection direction + distance (float): distance from origin to workplane + + Raises: + RuntimeError: Not suitable for BuildLine or BuildSketch + ValueError: x_dir perpendicular to projection_dir + + Returns: + Plane: workplane aligned for projection + """ + context: BuildPart = BuildPart._get_context("project_workplane") + + if context is not None and not isinstance(context, BuildPart): + raise RuntimeError( + "projection_workplane can only be used from a BuildPart context or algebra" + ) + + origin = origin.to_vector() if isinstance(origin, Vertex) else Vector(origin) + x_dir = ( + x_dir.to_vector().normalized() + if isinstance(x_dir, Vertex) + else Vector(x_dir).normalized() + ) + projection_dir = Vector(projection_dir).normalized() + + # Create a preliminary workplane without x direction set + workplane_origin = origin + projection_dir * distance + workplane = Plane(workplane_origin, z_dir=projection_dir) + + # Project a point off the origin to find the projected x direction + screen = Face.make_rect(1e9, 1e9, plane=workplane) + x_dir_point_axis = Axis(origin + x_dir, projection_dir) + projection = screen.find_intersection(x_dir_point_axis) + if not projection: + raise ValueError("x_dir perpendicular to projection_dir") + + # Set the workplane's x direction + workplane_x_dir = projection[0][0] - workplane_origin + workplane.x_dir = workplane_x_dir + workplane._calc_transforms() + + return workplane + + def revolve( profiles: Union[Face, Iterable[Face]] = None, axis: Axis = Axis.Z, @@ -269,16 +446,15 @@ def revolve( if not face_plane.contains(axis): raise ValueError("axis must be in the same plane as the face to revolve") - new_solid = Solid.revolve(profile, angle, axis) - locations = LocationList._get_context().locations if context else [Location()] - new_solids.extend([new_solid.moved(location) for location in locations]) + new_solids.append(Solid.revolve(profile, angle, axis)) + new_solid = Compound.make_compound(new_solids) if context is not None: context._add_to_context(*new_solids, clean=clean, mode=mode) elif clean: - new_solids = [solid.clean() for solid in new_solids] + new_solid = new_solid.clean() - return Part(Compound.make_compound(new_solids).wrapped) + return Part(new_solid.wrapped) def section( @@ -286,8 +462,8 @@ def section( section_by: Union[Plane, Iterable[Plane]] = Plane.XZ, height: float = 0.0, clean: bool = True, - mode: Mode = Mode.INTERSECT, -) -> Part: + mode: Mode = Mode.PRIVATE, +) -> Sketch: """Part Operation: section Slices current part at the given height by section_by or current workplane(s). @@ -325,102 +501,95 @@ def section( ) for plane in section_planes ] + if obj is None: + if context is not None and context._obj is not None: + obj = context.part + else: + raise ValueError("obj must be provided") + + new_objects = [obj.intersect(plane) for plane in planes] if context is not None: - context._add_to_context(*planes, faces_to_pending=False, clean=clean, mode=mode) - result = planes + context._add_to_context( + *new_objects, faces_to_pending=False, clean=clean, mode=mode + ) else: - result = [obj.intersect(plane) for plane in planes] if clean: - result = [r.clean() for r in result] + new_objects = [r.clean() for r in new_objects] - return Part(Compound.make_compound(result).wrapped) + return Sketch(Compound.make_compound(new_objects).wrapped) -#:TypeVar("SweepType"): Type of objects which can be swept -SweepType = Union[Edge, Wire, Face, Solid] - - -def sweep( - sections: Union[SweepType, Iterable[SweepType]] = None, - path: Union[Edge, Wire] = None, - multisection: bool = False, - is_frenet: bool = False, - transition: Transition = Transition.TRANSFORMED, - normal: VectorLike = None, - binormal: Union[Edge, Wire] = None, +def thicken( + to_thicken: Union[Face, Sketch] = None, + amount: float = None, + normal_override: VectorLike = None, + both: bool = False, clean: bool = True, mode: Mode = Mode.ADD, ) -> Part: - """Part Operation: sweep + """Part Operation: thicken - Sweep pending sketches/faces along path. + Create a solid(s) from a potentially non planar face(s) by thickening along the normals. Args: - sections (Union[Face, Compound]): cross sections to sweep into object - path (Union[Edge, Wire], optional): path to follow. - Defaults to context pending_edges. - multisection (bool, optional): sweep multiple on path. Defaults to False. - is_frenet (bool, optional): use frenet algorithm. Defaults to False. - transition (Transition, optional): discontinuity handling option. - Defaults to Transition.RIGHT. - normal (VectorLike, optional): fixed normal. Defaults to None. - binormal (Union[Edge, Wire], optional): guide rotation along path. Defaults to None. + to_thicken (Union[Face, Sketch], optional): object to thicken. Defaults to None. + amount (float, optional): distance to extrude, sign controls direction. Defaults to None. + normal_override (Vector, optional): The normal_override vector can be used to + indicate which way is 'up', potentially flipping the face normal direction + such that many faces with different normals all go in the same direction + (direction need only be +/- 90 degrees from the face normal). Defaults to None. + both (bool, optional): thicken in both directions. Defaults to False. clean (bool, optional): Remove extraneous internal structure. Defaults to True. - mode (Mode, optional): combination. Defaults to Mode.ADD. - """ - context: BuildPart = BuildPart._get_context("sweep") + mode (Mode, optional): combination mode. Defaults to Mode.ADD. - if path is None: - path_wire = context.pending_edges_as_wire - context.pending_edges = [] - else: - path_wire = Wire.make_wire([path]) if isinstance(path, Edge) else path + Raises: + ValueError: No object to extrude + ValueError: No target object - section_list = ( - [*sections] if isinstance(sections, (list, tuple, filter)) else [sections] - ) - validate_inputs(context, "sweep", section_list) + Returns: + Part: extruded object + """ + context: BuildPart = BuildPart._get_context("thicken") + validate_inputs(context, "thicken", to_thicken) - if all([s is None for s in section_list]): - if context is None or (context is not None and not context.pending_faces): - raise ValueError("No sections provided") - section_list = context.pending_faces - context.pending_faces = [] - context.pending_face_planes = [] - else: - section_list = [face for section in sections for face in section.faces()] + to_thicken_faces: list[Face] - if binormal is None and normal is not None: - binormal_mode = Vector(normal) - elif isinstance(binormal, Edge): - binormal_mode = Wire.make_wire([binormal]) + if to_thicken is None: + if context is not None and context.pending_faces: + # Get pending faces and face planes + to_thicken_faces = context.pending_faces + context.pending_faces = [] + context.pending_face_planes = [] + else: + raise ValueError("A face or sketch must be provided") else: - binormal_mode = binormal + # Get the faces from the face or sketch + to_thicken_faces = ( + [*to_thicken] + if isinstance(to_thicken, (tuple, list, filter)) + else to_thicken.faces() + ) - new_solids = [] - locations = LocationList._get_context().locations if context else [Location()] - for location in locations: - if multisection: - sections = [section.outer_wire() for section in section_list] - new_solid = Solid.sweep_multi( - sections, path_wire, True, is_frenet, binormal_mode - ).moved(location) - else: - for sec in section_list: - new_solid = Solid.sweep( - section=sec, - path=path_wire, - make_solid=True, - is_frenet=is_frenet, - mode=binormal_mode, - transition=transition, - ).moved(location) - new_solids.append(new_solid) + new_solids: list[Solid] = [] + + logger.info("%d face(s) to thicken", len(to_thicken_faces)) + + for face in to_thicken_faces: + normal_override = ( + normal_override if normal_override is not None else face.normal_at() + ) + for direction in [1, -1] if both else [1]: + new_solids.append( + face.thicken(depth=amount, normal_override=normal_override * direction) + ) if context is not None: context._add_to_context(*new_solids, clean=clean, mode=mode) - elif clean: - new_solids = [solid.clean() for solid in new_solids] + else: + if len(new_solids) > 1: + new_solids = [new_solids.pop().fuse(*new_solids)] + if clean: + new_solids = [solid.clean() for solid in new_solids] return Part(Compound.make_compound(new_solids).wrapped) diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index 5a0df615..750057ca 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -29,7 +29,7 @@ from __future__ import annotations from typing import Iterable, Union from build123d.build_enums import Mode -from build123d.topology import Compound, Edge, Face, ShapeList, Wire, Sketch +from build123d.topology import Compound, Curve, Edge, Face, ShapeList, Wire, Sketch from build123d.build_common import validate_inputs from build123d.build_sketch import BuildSketch @@ -42,7 +42,8 @@ def make_face( Create a face from the given perimeter edges. Args: - edges (Edge): sequence of perimeter edges + edges (Edge): sequence of perimeter edges. Defaults to all + sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch = BuildSketch._get_context("make_face") @@ -58,6 +59,8 @@ def make_face( validate_inputs(context, "make_face", outer_edges) pending_face = Face.make_from_wires(Wire.combine(outer_edges)[0]) + if pending_face.normal_at().Z < 0: # flip up-side-down faces + pending_face = -pending_face if context is not None: context._add_to_context(pending_face, mode=mode) @@ -75,7 +78,7 @@ def make_hull( Args: edges (Edge, optional): sequence of edges to hull. Defaults to all - pending and sketch edges. + sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ context: BuildSketch = BuildSketch._get_context("make_hull") @@ -94,9 +97,53 @@ def make_hull( validate_inputs(context, "make_hull", hull_edges) pending_face = Face.make_from_wires(Wire.make_convex_hull(hull_edges)) + if pending_face.normal_at().Z < 0: # flip up-side-down faces + pending_face = -pending_face if context is not None: context._add_to_context(pending_face, mode=mode) context.pending_edges = ShapeList() return Sketch(Compound.make_compound([pending_face]).wrapped) + + +def trace( + lines: Union[Curve, Edge, Wire, Iterable[Union[Curve, Edge, Wire]]] = None, + line_width: float = 1, + mode: Mode = Mode.ADD, +) -> Sketch: + """Sketch Operation: trace + + Convert edges, wires or pending edges into faces by sweeping a perpendicular line along them. + + Args: + lines (Union[Curve, Edge, Wire, Iterable[Union[Curve, Edge, Wire]]], optional): lines to trace. + Defaults to sketch pending edges. + line_width (float, optional): Defaults to 1. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + ValueError: No objects to trace + + Returns: + Sketch: Traced lines + """ + context: BuildSketch = BuildSketch._get_context("trace") + + if lines is not None: + trace_lines = [*lines] if isinstance(lines, (list, tuple, filter)) else [lines] + trace_edges = [e for l in trace_lines for e in l.edges()] + elif context is not None: + trace_edges = context.pending_edges + else: + raise ValueError("No objects to trace") + + new_faces = [] + for edge in trace_edges: + trace_pen = edge.perpendicular_line(line_width) + new_faces.extend(Face.sweep(trace_pen, edge).faces()) + if context is not None: + context._add_to_context(*new_faces, mode=mode) + context.pending_edges = ShapeList() + + return Sketch(Compound.make_compound(new_faces).wrapped) diff --git a/src/build123d/topology.py b/src/build123d/topology.py index 6b891b95..70d2bc78 100644 --- a/src/build123d/topology.py +++ b/src/build123d/topology.py @@ -34,7 +34,7 @@ # too-many-arguments, too-many-locals, too-many-public-methods, # too-many-statements, too-many-instance-attributes, too-many-branches import copy -import io as StringIO +import itertools import logging import os import platform @@ -44,13 +44,15 @@ from datetime import datetime from io import BytesIO from itertools import combinations -from math import degrees, radians, inf, pi, sqrt, sin, cos +from math import degrees, radians, inf, pi, sqrt, sin, cos, tan, copysign, ceil, floor from typing import ( Any, + Callable, Dict, Iterable, Iterator, Optional, + Protocol, Tuple, Type, TypeVar, @@ -58,13 +60,13 @@ overload, ) from typing import cast as tcast -from typing_extensions import Self, Literal import xml.etree.cElementTree as ET from zipfile import ZipFile, ZIP_DEFLATED, ZIP_STORED +from typing_extensions import Self, Literal -import ezdxf from anytree import NodeMixin, PreOrderIter, RenderTree from scipy.spatial import ConvexHull +from scipy.optimize import minimize from vtkmodules.vtkCommonDataModel import vtkPolyData from vtkmodules.vtkFiltersCore import vtkPolyDataNormals, vtkTriangleFilter @@ -124,6 +126,7 @@ from OCP.BRepOffsetAPI import ( BRepOffsetAPI_MakeFilling, BRepOffsetAPI_MakeOffset, + BRepOffsetAPI_MakePipe, BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections, @@ -160,6 +163,7 @@ Geom_TrimmedCurve, ) from OCP.Geom2d import Geom2d_Curve, Geom2d_Line +from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve from OCP.GeomAbs import GeomAbs_C0, GeomAbs_Intersection, GeomAbs_JoinType from OCP.GeomAPI import ( @@ -167,6 +171,7 @@ GeomAPI_PointsToBSpline, GeomAPI_PointsToBSplineSurface, GeomAPI_ProjectPointOnSurf, + GeomAPI_ProjectPointOnCurve, ) from OCP.GeomConvert import GeomConvert from OCP.GeomFill import ( @@ -182,10 +187,12 @@ gp_Dir, gp_Dir2d, gp_Elips, + gp_Lin2d, gp_Pnt, gp_Pnt2d, gp_Trsf, gp_Vec, + gp_Vec2d, ) # properties used to store mass calculation result @@ -203,11 +210,20 @@ from OCP.Quantity import Quantity_Color from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds from OCP.ShapeCustom import ShapeCustom, ShapeCustom_RestrictionParameters -from OCP.ShapeFix import ShapeFix_Face, ShapeFix_Shape, ShapeFix_Solid +from OCP.ShapeFix import ( + ShapeFix_Face, + ShapeFix_Shape, + ShapeFix_Solid, + ShapeFix_Wireframe, +) from OCP.ShapeUpgrade import ShapeUpgrade_UnifySameDomain # for catching exceptions -from OCP.Standard import Standard_Failure, Standard_NoSuchObject +from OCP.Standard import ( + Standard_Failure, + Standard_NoSuchObject, + Standard_ConstructionError, +) from OCP.StdFail import StdFail_NotDone from OCP.StdPrs import StdPrs_BRepFont from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder @@ -259,8 +275,10 @@ FontStyle, FrameMethod, GeomType, + Keep, Kind, PositionMode, + Side, SortBy, Transition, Unit, @@ -299,6 +317,7 @@ ta.TopAbs_SHELL: "Shell", ta.TopAbs_SOLID: "Solid", ta.TopAbs_COMPOUND: "Compound", + ta.TopAbs_COMPSOLID: "CompSolid", } shape_properties_LUT = { @@ -309,6 +328,7 @@ ta.TopAbs_SHELL: BRepGProp.SurfaceProperties_s, ta.TopAbs_SOLID: BRepGProp.VolumeProperties_s, ta.TopAbs_COMPOUND: BRepGProp.VolumeProperties_s, + ta.TopAbs_COMPSOLID: BRepGProp.VolumeProperties_s, } inverse_shape_LUT = {v: k for k, v in shape_LUT.items()} @@ -321,6 +341,7 @@ ta.TopAbs_SHELL: TopoDS.Shell_s, ta.TopAbs_SOLID: TopoDS.Solid_s, ta.TopAbs_COMPOUND: TopoDS.Compound_s, + ta.TopAbs_COMPSOLID: TopoDS.CompSolid_s, } geom_LUT = { ta.TopAbs_VERTEX: "Vertex", @@ -330,6 +351,7 @@ ta.TopAbs_SHELL: "Shell", ta.TopAbs_SOLID: "Solid", ta.TopAbs_COMPOUND: "Compound", + ta.TopAbs_COMPSOLID: "Compound", } @@ -466,6 +488,29 @@ def tangent_at( return Vector(gp_Dir(res)) + def tangent_angle_at( + self, + location_param: float = 0.5, + position_mode: PositionMode = PositionMode.LENGTH, + plane: Plane = Plane.XY, + ) -> float: + """tangent_angle_at + + Compute the tangent angle at the specified location + + Args: + location_param (float, optional): distance or parameter value. Defaults to 0.5. + position_mode (PositionMode, optional): position calculation mode. + Defaults to PositionMode.LENGTH. + plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY. + + Returns: + float: angle in degrees between 0 and 360 + """ + tan_vector = self.tangent_at(location_param, position_mode) + angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0 + return angle + def normal(self) -> Vector: """Calculate the normal Vector. Only possible for planar curves. @@ -546,6 +591,11 @@ def radius(self) -> float: raise ValueError("Shape could not be reduced to a circle") from err return circ.Radius() + @property + def is_forward(self) -> bool: + """Does the Edge/Wire loop forward or reverse""" + return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD + def is_closed(self) -> bool: """Are the start and end points equal?""" return BRep_Tool.IsClosed_s(self.wrapped) @@ -675,14 +725,136 @@ def locations( self.location_at(d, position_mode, frame_method, planar) for d in distances ] - def __matmul__(self: Union[Edge, Wire], position: float): - """Position on wire operator""" + def __matmul__(self: Union[Edge, Wire], position: float) -> Vector: + """Position on wire operator @""" return self.position_at(position) - def __mod__(self: Union[Edge, Wire], position: float): - """Tangent on wire operator""" + def __mod__(self: Union[Edge, Wire], position: float) -> Vector: + """Tangent on wire operator %""" return self.tangent_at(position) + def offset_2d( + self, + distance: float, + kind: Kind = Kind.ARC, + side: Side = Side.BOTH, + closed: bool = True, + ) -> Union[Edge, Wire]: + """2d Offset + + Offsets a planar edge/wire + + Args: + distance (float): distance from edge/wire to offset + kind (Kind, optional): offset corner transition. Defaults to Kind.ARC. + side (Side, optional): side to place offset. Defaults to Side.BOTH. + closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT + offset. Defaults to True. + Raises: + RuntimeError: Multiple Wires generated + RuntimeError: Unexpected result type + + Returns: + Wire: offset wire + """ + kind_dict = { + Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, + Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, + Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, + } + line = self if isinstance(self, Wire) else Wire.make_wire([self]) + + # Avoiding a bug when the wire contains a single Edge + if len(line.edges()) == 1: + edge = line.edges()[0] + edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)] + topods_wire = Wire.make_wire(edges).wrapped + else: + topods_wire = line.wrapped + + offset_builder = BRepOffsetAPI_MakeOffset() + offset_builder.Init(kind_dict[kind]) + # offset_builder.SetApprox(True) + offset_builder.AddWire(topods_wire) + offset_builder.Perform(distance) + + obj = downcast(offset_builder.Shape()) + if isinstance(obj, TopoDS_Compound): + for i, el in enumerate(Compound(obj)): + offset_wire = Wire(el.wrapped) + if i >= 1: + raise RuntimeError("Multiple Wires generated") + elif isinstance(obj, TopoDS_Wire): + offset_wire = Wire(obj) + else: + raise RuntimeError("Unexpected result type") + + if side != Side.BOTH: + # Find and remove the end arcs + offset_edges = offset_wire.edges() + edges_to_keep = [[], [], []] + i = 0 + for edge in offset_edges: + if edge.geom_type() == "CIRCLE" and ( + edge.arc_center == line.position_at(0) + or edge.arc_center == line.position_at(1) + ): + i += 1 + else: + edges_to_keep[i].append(edge) + edges_to_keep[0] += edges_to_keep[2] + wires = [Wire.make_wire(edges) for edges in edges_to_keep[0:2]] + centers = [w.position_at(0.5) for w in wires] + angles = [ + line.tangent_at(0).get_signed_angle(c - line.position_at(0)) + for c in centers + ] + if side == Side.LEFT: + offset_wire = wires[int(angles[0] > angles[1])] + else: + offset_wire = wires[int(angles[0] <= angles[1])] + + if closed: + self0 = line.position_at(0) + self1 = line.position_at(1) + end0 = offset_wire.position_at(0) + end1 = offset_wire.position_at(1) + if (self0 - end0).length - distance <= TOLERANCE: + e0 = Edge.make_line(self0, end0) + e1 = Edge.make_line(self1, end1) + else: + e0 = Edge.make_line(self0, end1) + e1 = Edge.make_line(self1, end0) + offset_wire = Wire.make_wire( + line.edges() + offset_wire.edges() + [e0, e1] + ) + + offset_edges = offset_wire.edges() + if len(offset_edges) == 1: + return offset_edges[0] + else: + return offset_wire + + def perpendicular_line(self, length: float, plane: Plane = Plane.XY) -> Edge: + """perpendicular_line + + Create a line on the given plane perpendicular to and centered on beginning of self + + Args: + length (float): line length + plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY. + + Returns: + Edge: perpendicular line + """ + start = self.position_at(0) + local_plane = Plane(origin=start, x_dir=self.tangent_at(0), z_dir=plane.z_dir) + line = Edge.make_line( + start + local_plane.y_dir * length / 2, + start - local_plane.y_dir * length / 2, + ) + return line + def project( self, face: Face, direction: VectorLike, closest: bool = True ) -> Union[Mixin1D, list[Mixin1D]]: @@ -748,7 +920,17 @@ def fillet(self, radius: float, edge_list: Iterable[Edge]) -> Self: for native_edge in native_edges: fillet_builder.Add(radius, native_edge) - return self.__class__(fillet_builder.Shape()) + try: + new_shape = self.__class__(fillet_builder.Shape()) + if not new_shape.is_valid(): + raise Standard_Failure + except (StdFail_NotDone, Standard_Failure) as err: + raise ValueError( + f"Failed creating a fillet with radius of {radius}, try a smaller value" + f" or use max_fillet() to find the largest valid fillet radius" + ) from err + + return new_shape def max_fillet( self, @@ -788,9 +970,15 @@ def __max_fillet(window_min: float, window_max: float, current_iteration: int): f"Failed to find the max value within {tolerance} in {max_iterations}" ) + fillet_builder = BRepFilletAPI_MakeFillet(self.wrapped) + + for native_edge in native_edges: + fillet_builder.Add(window_mid, native_edge) + # Do these numbers work? - if not try with the smaller window try: - if not self.fillet(window_mid, edge_list).is_valid(): + new_shape = self.__class__(fillet_builder.Shape()) + if not new_shape.is_valid(): raise fillet_exception except fillet_exception: return __max_fillet(window_min, window_mid, current_iteration + 1) @@ -807,6 +995,8 @@ def __max_fillet(window_min: float, window_max: float, current_iteration: int): if not self.is_valid(): raise ValueError("Invalid Shape") + native_edges = [e.wrapped for e in edge_list] + # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform # specific exceptions are required. if platform.system() == "Darwin": @@ -858,7 +1048,17 @@ def chamfer( chamfer_builder.Add( distance1, distance2, native_edge, TopoDS.Face_s(face) ) # NB: edge_face_map return a generic TopoDS_Shape - return self.__class__(chamfer_builder.Shape()) + + try: + new_shape = self.__class__(chamfer_builder.Shape()) + if not new_shape.is_valid(): + raise Standard_Failure + except (StdFail_NotDone, Standard_Failure) as err: + raise ValueError( + "Failed creating a chamfer, try a smaller length value(s)" + ) from err + + return new_shape def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: """Return center of object @@ -889,16 +1089,16 @@ def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector: middle = self.bounding_box().center() return middle - def shell( + def hollow( self, faces: Optional[Iterable[Face]], thickness: float, tolerance: float = 0.0001, kind: Kind = Kind.ARC, ) -> Solid: - """Shell + """Hollow - Make a shelled solid of self. + Return the outer shelled solid of self. Args: faces (Optional[Iterable[Face]]): faces to be removed, @@ -912,7 +1112,7 @@ def shell( ValueError: Kind.TANGENT not supported Returns: - Solid: A shelled solid. + Solid: A hollow solid. """ if kind == Kind.TANGENT: raise ValueError("Kind.TANGENT not supported") @@ -1102,6 +1302,16 @@ class Shape(NodeMixin): parent (Compound, optional): assembly parent. Defaults to None. children (list[Shape], optional): assembly children - only valid for Compounds. Defaults to None. + + Attributes: + wrapped (TopoDS_Shape): the OCP object + label (str): user assigned label + color (Color): object color + material (str): user assigned material + joints (dict[str:Joint]): dictionary of joints bound to this object (Solid only) + children (Shape): list of assembly children of this object (Compound only) + topo_parent (Shape): assembly parent of this object + """ _dim = None @@ -1175,6 +1385,52 @@ def orientation(self, rotations: VectorLike): loc.orientation = rotations self.location = loc + @property + def is_manifold(self) -> bool: + """is_manifold + + Check if each edge in the given Shape has exactly two faces associated with it + (skipping degenerate edges). If so, the shape is manifold. + + Returns: + bool: is the shape manifold or water tight + """ + if isinstance(self, Compound): + return all([Export3MF.is_manifold(sub_shape) for sub_shape in self]) + else: + # Create an empty indexed data map to store the edges and their corresponding faces. + map = TopTools_IndexedDataMapOfShapeListOfShape() + + # Fill the map with edges and their associated faces in the given shape. Each edge in + # the map is associated with a list of faces that share that edge. + TopExp.MapShapesAndAncestors_s( + self.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, map + ) + + # Iterate over the edges in the map and checks if each edge is non-degenerate and has + # exactly two faces associated with it. + for i in range(map.Extent()): + # Access each edge in the map sequentially + edge = downcast(map.FindKey(i + 1)) + + vertex0 = TopoDS_Vertex() + vertex1 = TopoDS_Vertex() + + # Extract the two vertices of the current edge and stores them in vertex0 and vertex1. + TopExp.Vertices_s(edge, vertex0, vertex1) + + # Check if both vertices are null and if they are the same vertex. If so, the edge is + # considered degenerate (i.e., has zero length), and it is skipped. + if vertex0.IsNull() and vertex1.IsNull() and vertex0.IsSame(vertex1): + continue + + # Check if the current edge has exactly two faces associated with it. If not, it means + # the edge is not shared by exactly two faces, indicating that the shape is not manifold. + if map.FindFromIndex(i + 1).Extent() != 2: + return False + + return True + class _DisplayNode(NodeMixin): """Used to create anytree structures from TopoDS_Shapes""" @@ -1317,7 +1573,7 @@ def show_topology( return result def __add__(self, other: Union[list[Shape], Shape]) -> Self: - # identify vectorized operations + """fuse shape to self operator +""" others = other if isinstance(other, (list, tuple)) else [other] if not all([type(other)._dim == type(self)._dim for other in others]): @@ -1346,7 +1602,7 @@ def __add__(self, other: Union[list[Shape], Shape]) -> Self: return new_shape def __sub__(self, other: Shape) -> Self: - # identify vectorized operations + """cut shape from self operator -""" others = other if isinstance(other, (list, tuple)) else [other] if not all([type(other)._dim == type(self)._dim for other in others]): @@ -1376,7 +1632,7 @@ def __sub__(self, other: Shape) -> Self: return new_shape def __and__(self, other: Shape) -> Self: - # identify vectorized operations + """intersect shape with self operator &""" others = other if isinstance(other, (list, tuple)) else [other] if self.wrapped is None or (isinstance(other, Shape) and other.wrapped is None): @@ -1396,6 +1652,7 @@ def __and__(self, other: Shape) -> Self: return new_shape def __rmul__(self, other): + """right multiply for positioning operator *""" if not ( isinstance(other, (list, tuple)) and all([isinstance(o, (Location, Plane)) for o in other]) @@ -1405,7 +1662,7 @@ def __rmul__(self, other): ) return [loc * self for loc in other] - def clean(self) -> Shape: + def clean(self) -> Self: """clean Remove internal edges @@ -1423,7 +1680,7 @@ def clean(self) -> Shape: warnings.warn(f"Unable to clean {self}") return self - def fix(self) -> Shape: + def fix(self) -> Self: """fix - try to fix shape if not valid""" if not self.is_valid(): shape_copy: Shape = copy.deepcopy(self, None) @@ -1434,7 +1691,7 @@ def fix(self) -> Shape: return self @classmethod - def cast(cls, obj: TopoDS_Shape, for_construction: bool = False) -> Shape: + def cast(cls, obj: TopoDS_Shape, for_construction: bool = False) -> Self: "Returns the right type of wrapper, given a OCCT object" new_shape = None @@ -1497,23 +1754,6 @@ def export_stl( return writer.Write(self.wrapped, file_name) - def export_3mf( - self, file_name: str, tolerance: float, angular_tolerance: float, unit: Unit - ): - """export_3mf - - Exports a shape to a specified 3MF file. - - Args: - file_name (str): name of 3mf file - tolerance (float): linear tolerance for tesselation - angular_tolerance (float): angular tolerance for tesselation - unit (Unit): model unit - """ - tmfw = ThreeMF(self, tolerance, angular_tolerance, unit) - with open(file_name, "wb") as three_mf_file: - tmfw.write_3mf(three_mf_file) - def export_step(self, file_name: str, **kwargs) -> IFSelect_ReturnStatus: """Export this shape to a STEP file. @@ -1553,108 +1793,6 @@ def export_brep(self, file: Union[str, BytesIO]) -> bool: return True if return_value is None else return_value - def export_svg( - self, - file_name: str, - viewport_origin: VectorLike, - viewport_up: VectorLike = (0, 0, 1), - look_at: VectorLike = None, - svg_opts: dict = None, - ): - """Export shape to SVG file - - Export self to an SVG file with the provided options - - Args: - file_name (str): file name - svg_opts (dict, optional): options dictionary. Defaults to None. - - SVG Options - e.g. svg_opts = {"pixel_scale":50}: - - Other Parameters: - width (int): Viewport width in pixels. Defaults to 240. - height (int): Viewport width in pixels. Defaults to 240. - pixel_scale (float): Pixels per CAD unit. - Defaults to None (calculated based on width & height). - units (str): SVG document units. Defaults to "mm". - margin_left (int): Defaults to 20. - margin_top (int): Defaults to 20. - show_axes (bool): Display an axis indicator. Defaults to True. - axes_scale (float): Length of axis indicator in global units. Defaults to 1.0. - stroke_width (float): Width of visible edges. - Defaults to None (calculated based on unit_scale). - stroke_color (tuple[int]): Visible stroke color. Defaults to RGB(0, 0, 0). - hidden_color (tuple[int]): Hidden stroke color. Defaults to RBG(160, 160, 160). - show_hidden (bool): Display hidden lines. Defaults to True. - - """ - svg = SVG.get_svg(self, viewport_origin, viewport_up, look_at, svg_opts) - with open(file_name, "w", encoding="utf-8") as file: - file.write(svg) - - def export_dxf( - self, - fname: str, - approx_option: ApproxOption = ApproxOption.NONE, - tolerance: float = 1e-3, - unit: Unit = Unit.MILLIMETER, - ): - """export_dxf - - Export shape to DXF. Works with 2D sections. - - Args: - fname (str): output filename. - approx (ApproxOption, optional): Approximation strategy. NONE means no approximation is - applied. SPLINE results in all splines being approximated as cubic splines. - ARC results in all curves being approximated as arcs and straight segments. - Defaults to Approximation.NONE. - tolerance (float, optional): Approximation tolerance. Defaults to 1e-3. - """ - dxf = ezdxf.new() - msp = dxf.modelspace() - if unit == Unit.MILLIMETER: - dxf.units = ezdxf.units.MM - elif unit == Unit.CENTIMETER: - dxf.units = ezdxf.units.CM - elif unit == Unit.INCH: - dxf.units = ezdxf.units.IN - elif unit == Unit.FOOT: - dxf.units = ezdxf.units.FT - else: - raise ValueError("unit not supported") - - plane = Plane(self.location) - - if approx_option == ApproxOption.SPLINE: - edges = [ - e.to_splines() if e.geom_type() == "BSPLINE" else e - for e in self.edges() - ] - - elif approx_option == ApproxOption.ARC: - edges = [] - - # this is needed to handle free wires - for wire in self.wires(): - edges.extend(Face.make_from_wires(wire).to_arcs(tolerance).edges()) - - else: - edges = self.edges() - - dxf_converters = { - "LINE": DXF._dxf_line, - "CIRCLE": DXF._dxf_circle, - "ELLIPSE": DXF._dxf_ellipse, - "BSPLINE": DXF._dxf_spline, - } - - for edge in edges: - conv = dxf_converters.get(edge.geom_type(), DXF._dxf_spline) - conv(edge, msp, plane) - - dxf.saveas(fname) - def geom_type(self) -> Geoms: """Gets the underlying geometry type. @@ -1739,7 +1877,7 @@ def is_equal(self, other: Shape) -> bool: return self.wrapped.IsEqual(other.wrapped) def __eq__(self, other) -> bool: - """Are shapes same?""" + """Are shapes same operator ==""" return self.is_same(other) if isinstance(other, Shape) else False def is_valid(self) -> bool: @@ -1765,7 +1903,7 @@ def bounding_box(self, tolerance: float = None) -> BoundBox: """ return BoundBox._from_topo_ds(self.wrapped, tolerance=tolerance) - def mirror(self, mirror_plane: Plane = None) -> Shape: + def mirror(self, mirror_plane: Plane = None) -> Self: """ Applies a mirror transform to this Shape. Does not duplicate objects about the plane. @@ -1898,6 +2036,14 @@ def vertices(self) -> ShapeList[Vertex]: vertex.topo_parent = self return vertex_list + def vertex(self) -> Vertex: + """Return the Vertex""" + vertices = self.vertices() + vertex_count = len(vertices) + if vertex_count != 1: + warnings.warn(f"Found {vertex_count} vertices, returning first") + return vertices[0] + def edges(self) -> ShapeList[Edge]: """edges - all the edges in this Shape""" edge_list = ShapeList( @@ -1911,6 +2057,14 @@ def edges(self) -> ShapeList[Edge]: edge.topo_parent = self return edge_list + def edge(self) -> Edge: + """Return the Edge""" + edges = self.edges() + edge_count = len(edges) + if edge_count != 1: + warnings.warn(f"Found {edge_count} edges, returning first") + return edges[0] + def compounds(self) -> ShapeList[Compound]: """compounds - all the compounds in this Shape""" if isinstance(self, Compound): @@ -1921,10 +2075,26 @@ def compounds(self) -> ShapeList[Compound]: sub_compounds = [] return ShapeList(sub_compounds) + def compound(self) -> Compound: + """Return the Compound""" + compounds = self.compounds() + compound_count = len(compounds) + if compound_count != 1: + warnings.warn(f"Found {compound_count} compounds, returning first") + return compounds[0] + def wires(self) -> ShapeList[Wire]: """wires - all the wires in this Shape""" return ShapeList([Wire(i) for i in self._entities(Wire.__name__)]) + def wire(self) -> Wire: + """Return the Wire""" + wires = self.wires() + wire_count = len(wires) + if wire_count != 1: + warnings.warn(f"Found {wire_count} wires, returning first") + return wires[0] + def faces(self) -> ShapeList[Face]: """faces - all the faces in this Shape""" face_list = ShapeList([Face(i) for i in self._entities(Face.__name__)]) @@ -1932,14 +2102,39 @@ def faces(self) -> ShapeList[Face]: face.topo_parent = self return face_list + def face(self) -> Face: + """Return the Face""" + faces = self.faces() + face_count = len(faces) + if face_count != 1: + msg = f"Found {face_count} faces, returning first" + warnings.warn(msg) + return faces[0] + def shells(self) -> ShapeList[Shell]: """shells - all the shells in this Shape""" return ShapeList([Shell(i) for i in self._entities(Shell.__name__)]) + def shell(self) -> Shell: + """Return the Shell""" + shells = self.shells() + shell_count = len(shells) + if shell_count != 1: + warnings.warn(f"Found {shell_count} shells, returning first") + return shells[0] + def solids(self) -> ShapeList[Solid]: """solids - all the solids in this Shape""" return ShapeList([Solid(i) for i in self._entities(Solid.__name__)]) + def solid(self) -> Solid: + """Return the Solid""" + solids = self.solids() + solid_count = len(solids) + if solid_count != 1: + warnings.warn(f"Found {solid_count} solids, returning first") + return solids[0] + @property def area(self) -> float: """area -the surface area of all faces in this Shape""" @@ -1954,7 +2149,7 @@ def volume(self) -> float: # when density == 1, mass == volume return Shape.compute_mass(self) - def _apply_transform(self, transformation: gp_Trsf) -> Shape: + def _apply_transform(self, transformation: gp_Trsf) -> Self: """Private Apply Transform Apply the provided transformation matrix to a copy of Shape @@ -1972,7 +2167,7 @@ def _apply_transform(self, transformation: gp_Trsf) -> Shape: shape_copy.wrapped = downcast(transformed_shape) return shape_copy - def rotate(self, axis: Axis, angle: float) -> Shape: + def rotate(self, axis: Axis, angle: float) -> Self: """rotate a copy Rotates a shape around an axis. @@ -1989,7 +2184,7 @@ def rotate(self, axis: Axis, angle: float) -> Shape: return self._apply_transform(transformation) - def translate(self, vector: VectorLike) -> Shape: + def translate(self, vector: VectorLike) -> Self: """Translates this shape through a transformation. Args: @@ -2004,7 +2199,7 @@ def translate(self, vector: VectorLike) -> Shape: return self._apply_transform(transformation) - def scale(self, factor: float) -> Shape: + def scale(self, factor: float) -> Self: """Scales this shape through a transformation. Args: @@ -2019,7 +2214,7 @@ def scale(self, factor: float) -> Shape: return self._apply_transform(transformation) - def __deepcopy__(self, memo) -> Shape: + def __deepcopy__(self, memo) -> Self: """Return deepcopy of self""" # The wrapped object is a OCCT TopoDS_Shape which can't be pickled or copied # with the standard python copy/deepcopy, so create a deepcopy 'memo' with this @@ -2032,7 +2227,7 @@ def __deepcopy__(self, memo) -> Shape: setattr(result, key, copy.deepcopy(value, memo)) return result - def __copy__(self) -> Shape: + def __copy__(self) -> Self: """Return shallow copy or reference of self Create an copy of this Shape that shares the underlying TopoDS_TShape. @@ -2047,7 +2242,7 @@ def __copy__(self) -> Shape: reference.wrapped.TShape(self.wrapped.TShape()) return reference - def copy(self) -> Shape: + def copy(self) -> Self: """Here for backwards compatibility with cq-editor""" warnings.warn( "copy() will be deprecated - use copy.copy() or copy.deepcopy() instead", @@ -2056,7 +2251,7 @@ def copy(self) -> Shape: ) return copy.deepcopy(self, None) - def transform_shape(self, t_matrix: Matrix) -> Shape: + def transform_shape(self, t_matrix: Matrix) -> Self: """Apply affine transform without changing type Transforms a copy of this Shape by the provided 3D affine transformation matrix. @@ -2069,15 +2264,18 @@ def transform_shape(self, t_matrix: Matrix) -> Shape: Returns: Shape: copy of transformed shape with all objects keeping their type """ - transformed = Shape.cast( - BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape() - ) - new_shape = copy.deepcopy(self, None) - new_shape.wrapped = transformed.wrapped + if isinstance(self, Vertex): + new_shape = Vertex(*t_matrix.multiply(self.to_vector())) + else: + transformed = Shape.cast( + BRepBuilderAPI_Transform(self.wrapped, t_matrix.wrapped.Trsf()).Shape() + ) + new_shape = copy.deepcopy(self, None) + new_shape.wrapped = transformed.wrapped return new_shape - def transform_geometry(self, t_matrix: Matrix) -> Shape: + def transform_geometry(self, t_matrix: Matrix) -> Self: """Apply affine transform WARNING: transform_geometry will sometimes convert lines and circles to @@ -2160,6 +2358,26 @@ def moved(self, loc: Location) -> Self: shape_copy.wrapped = downcast(shape_copy.wrapped.Moved(loc.wrapped)) return shape_copy + def relocate(self, loc: Location): + """Change the location of self while keeping it geometrically similar + + Args: + loc (Location): new location to set for self + """ + if self.location != loc: + old_ax = gp_Ax3() + old_ax.Transform(self.location.wrapped.Transformation()) + + new_ax = gp_Ax3() + new_ax.Transform(loc.wrapped.Transformation()) + + trsf = gp_Trsf() + trsf.SetDisplacement(new_ax, old_ax) + builder = BRepBuilderAPI_Transform(self.wrapped, trsf, True, True) + + self.wrapped = builder.Shape() + self.wrapped.Location(loc.wrapped) + def distance_to_with_closest_points( self, other: Union[Shape, VectorLike] ) -> tuple[float, Vector, Vector]: @@ -2192,7 +2410,7 @@ def _bool_op( args: Iterable[Shape], tools: Iterable[Shape], operation: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter], - ) -> Shape: + ) -> Self: """Generic boolean operation Args: @@ -2221,7 +2439,7 @@ def _bool_op( return Shape.cast(operation.Shape()) - def cut(self, *to_cut: Shape) -> Shape: + def cut(self, *to_cut: Shape) -> Self: """Remove the positional arguments from this Shape. Args: @@ -2235,7 +2453,7 @@ def cut(self, *to_cut: Shape) -> Shape: return self._bool_op((self,), to_cut, cut_op) - def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Shape: + def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Self: """fuse Fuse a sequence of shapes into a single shape. @@ -2259,7 +2477,7 @@ def fuse(self, *to_fuse: Shape, glue: bool = False, tol: float = None) -> Shape: return return_value - def intersect(self, *to_intersect: Shape) -> Shape: + def intersect(self, *to_intersect: Shape) -> Self: """Intersection of the positional arguments and this Shape. Args: @@ -2312,19 +2530,58 @@ def faces_intersected_by_axis( return ShapeList([Face(face) for face in faces]) - def split(self, *splitters: Shape) -> Shape: - """Split this shape with the positional arguments. + def split(self, plane: Plane, keep: Keep = Keep.TOP) -> Self: + """split + + Split this shape by the provided plane. Args: - *splitters: Shape: + plane (Plane): plane to segment shape + keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. Returns: - + Shape: result of split """ + shape_list = TopTools_ListOfShape() + shape_list.Append(self.wrapped) + + # Define the splitting plane + tool = Face.make_plane(plane).wrapped + tool_list = TopTools_ListOfShape() + tool_list.Append(tool) + + # Create the splitter algorithm + splitter = BRepAlgoAPI_Splitter() + + # Set the shape to be split and the splitting tool (plane face) + splitter.SetArguments(shape_list) + splitter.SetTools(tool_list) - split_op = BRepAlgoAPI_Splitter() + # Perform the splitting operation + splitter.Build() - return self._bool_op((self,), splitters, split_op) + if keep == Keep.BOTH: + result = Compound(downcast(splitter.Shape())) + else: + parts = [shape for shape in Compound(downcast(splitter.Shape()))] + tops = [] + bottoms = [] + for part in parts: + if plane.to_local_coords(part).center().Z >= 0: + tops.append(part) + else: + bottoms.append(part) + if keep == Keep.TOP: + if len(tops) == 1: + result = tops[0] + else: + result = Compound.make_compound(tops) + elif keep == Keep.BOTTOM: + if len(bottoms) == 1: + result = bottoms[0] + else: + result = Compound.make_compound(bottoms) + return result def distance(self, other: Shape) -> float: """Minimal distance between two shapes @@ -2459,10 +2716,8 @@ def to_vtk_poly_data( angular_tolerance: float: (Default value = 0.1) normals: bool: (Default value = True) - Returns: - + Returns: data object in VTK consisting of points, vertices, lines, and polygons """ - vtk_shape = IVtkOCC_Shape(self.wrapped) shape_data = IVtkVTK_ShapeData() shape_mesher = IVtkOCC_ShapeMesher() @@ -2479,11 +2734,11 @@ def to_vtk_poly_data( shape_mesher.Build(vtk_shape, shape_data) - return_value = shape_data.getVtkPolyData() + vtk_poly_data = shape_data.getVtkPolyData() # convert to triangles and split edges t_filter = vtkTriangleFilter() - t_filter.SetInputData(return_value) + t_filter.SetInputData(vtk_poly_data) t_filter.Update() return_value = t_filter.GetOutput() @@ -2523,7 +2778,7 @@ def _repr_javascript_(self): def transformed( self, rotate: VectorLike = (0, 0, 0), offset: VectorLike = (0, 0, 0) - ) -> Shape: + ) -> Self: """Transform Shape Rotate and translate the Shape by the three angles (in degrees) and offset. @@ -2596,14 +2851,12 @@ def project_faces( path: Union[Wire, Edge], start: float = 0, ) -> Compound: - """Projected 3D text following the given path on Shape + """Projected Faces following the given path on Shape - Create 3D text using projection by positioning each face of - the planar text normal to the shape along the path and projecting - onto the surface. If depth is not zero, the resulting face is - thickened to the provided depth. + Project by positioning each face of to the shape along the path and + projecting onto the surface. - Note that projection may result in text distortion depending on + Note that projection may result in distortion depending on the shape at a position along the path. .. image:: projectText.png @@ -2611,10 +2864,10 @@ def project_faces( Args: faces (Union[list[Face], Compound]): faces to project path: Path on the Shape to follow - start: Relative location on path to start the text. Defaults to 0. + start: Relative location on path to start the faces. Defaults to 0. Returns: - The projected faces either as 2D or 3D + The projected faces """ path_length = path.length @@ -2624,25 +2877,29 @@ def project_faces( if isinstance(faces, Compound): faces = faces.faces() + first_face_min_x = faces[0].bounding_box().min.X + logger.debug("projecting %d face(s)", len(faces)) - # Position each text face normal to the surface along the path and project to the surface + # Position each face normal to the surface along the path and project to the surface projected_faces = [] for face in faces: bbox = face.bounding_box() face_center_x = (bbox.min.X + bbox.max.X) / 2 - relative_position_on_wire = start + face_center_x / path_length + relative_position_on_wire = ( + start + (face_center_x - first_face_min_x) / path_length + ) path_position = path.position_at(relative_position_on_wire) path_tangent = path.tangent_at(relative_position_on_wire) - (surface_point, surface_normal) = self.find_intersection( - Axis(path_position, path_position - shape_center) - )[0] + projection_axis = Axis(path_position, shape_center - path_position) + (surface_point, surface_normal) = self.find_intersection(projection_axis)[0] surface_normal_plane = Plane( origin=surface_point, x_dir=path_tangent, z_dir=surface_normal ) - projection_face: Face = face.translate( - (-face_center_x, 0, 0) - ).transform_shape(surface_normal_plane.reverse_transform) + projection_face: Face = surface_normal_plane.from_local_coords( + face.moved(Location((-face_center_x, 0, 0))) + ) + logger.debug("projecting face at %0.2f", relative_position_on_wire) projected_faces.append( projection_face.project_to_shape(self, surface_normal * -1)[0] @@ -2652,115 +2909,272 @@ def project_faces( return Compound.make_compound(projected_faces) + def _extrude( + self, direction: VectorLike + ) -> Union[Edge, Face, Shell, Solid, Compound]: + """_extrude -# This TypeVar allows IDEs to see the type of objects within the ShapeList -T = TypeVar("T", bound=Union[Shape, Vector]) + Extrude self in the provided direction. + Args: + direction (VectorLike): direction and magnitue of extrusion -class ShapeList(list[T]): - """Subclass of list with custom filter and sort methods appropriate to CAD""" + Raises: + ValueError: Unsupported class + RuntimeError: Generated invalid result - @property - def first(self) -> T: - """First element in the ShapeList""" - return self[0] + Returns: + Union[Edge, Face, Shell, Solid, Compound]: extruded shape + """ + direction = Vector(direction) - @property - def last(self) -> T: - """Last element in the ShapeList""" - return self[-1] + if not isinstance(self, (Vertex, Edge, Wire, Face, Shell)): + raise ValueError(f"extrude not supported for {type(self)}") + + prism_builder = BRepPrimAPI_MakePrism(self.wrapped, direction.wrapped) + new_shape = downcast(prism_builder.Shape()) + shape_type = new_shape.ShapeType() + + if shape_type == TopAbs_ShapeEnum.TopAbs_EDGE: + result = Edge(new_shape) + elif shape_type == TopAbs_ShapeEnum.TopAbs_FACE: + result = Face(new_shape) + elif shape_type == TopAbs_ShapeEnum.TopAbs_SHELL: + result = Shell(new_shape) + elif shape_type == TopAbs_ShapeEnum.TopAbs_SOLID: + result = Solid(new_shape) + elif shape_type == TopAbs_ShapeEnum.TopAbs_COMPSOLID: + solids = [] + explorer = TopExp_Explorer(new_shape, TopAbs_ShapeEnum.TopAbs_SOLID) + while explorer.More(): + topods_solid = downcast(explorer.Current()) + solids.append(Solid(topods_solid)) + explorer.Next() + result = Compound.make_compound(solids) + else: + raise RuntimeError("extrude produced an unexpected result") + return result - def filter_by( - self, - filter_by: Union[Axis, GeomType], - reverse: bool = False, - tolerance: float = 1e-5, - ) -> ShapeList[T]: - """filter by Axis or GeomType + @classmethod + def extrude( + cls, obj: Union[Vertex, Edge, Wire, Face, Shell], direction: VectorLike + ) -> Self: + """extrude - Either: - - filter objects of type planar Face or linear Edge by their normal or tangent - (respectively) and sort the results by the given axis, or - - filter the objects by the provided type. Note that not all types apply to all - objects. + Extrude a Shape in the provided direction. + * Vertices generate Edges + * Edges generate Faces + * Wires generate Shells + * Faces generate Solids + * Shells generate Compounds Args: - filter_by (Union[Axis,GeomType]): axis or geom type to filter and possibly sort by - reverse (bool, optional): invert the geom type filter. Defaults to False. - tolerance (float, optional): maximum deviation from axis. Defaults to 1e-5. + direction (VectorLike): direction and magnitue of extrusion Raises: - ValueError: Invalid filter_by type + ValueError: Unsupported class + RuntimeError: Generated invalid result Returns: - ShapeList: filtered list of objects + Union[Edge, Face, Shell, Solid, Compound]: extruded shape """ - if isinstance(filter_by, Axis): - planar_faces = filter( - lambda o: isinstance(o, Face) and o.geom_type() == "PLANE", self - ) - linear_edges = filter( - lambda o: isinstance(o, Edge) and o.geom_type() == "LINE", self - ) - - result = list( - filter( - lambda o: filter_by.is_parallel( - Axis(o.center(), o.normal_at(None)), tolerance - ), - planar_faces, - ) - ) - result.extend( - list( - filter( - lambda o: filter_by.is_parallel( - Axis(o.position_at(0), o.tangent_at(0)), tolerance - ), - linear_edges, - ) - ) - ) - return_value = ShapeList(result).sort_by(filter_by) - - elif isinstance(filter_by, GeomType): - if reverse: - return_value = ShapeList( - filter(lambda o: o.geom_type() != filter_by.name, self) - ) - else: - return_value = ShapeList( - filter(lambda o: o.geom_type() == filter_by.name, self) - ) - else: - raise ValueError(f"Unable to filter_by type {type(filter_by)}") - - return return_value + return obj._extrude(direction) - def filter_by_position( + def project_to_viewport( self, - axis: Axis, - minimum: float, - maximum: float, - inclusive: tuple[bool, bool] = (True, True), - ) -> ShapeList[T]: - """filter by position + viewport_origin: VectorLike, + viewport_up: VectorLike = (0, 0, 1), + look_at: VectorLike = None, + ) -> tuple[ShapeList[Edge], ShapeList[Edge]]: + """project_to_viewport - Filter and sort objects by the position of their centers along given axis. - min and max values can be inclusive or exclusive depending on the inclusive tuple. + Project a shape onto a viewport returning visible and hidden Edges. Args: - axis (Axis): axis to sort by - minimum (float): minimum value - maximum (float): maximum value - inclusive (tuple[bool, bool], optional): include min,max values. - Defaults to (True, True). + viewport_origin (VectorLike): location of viewport + viewport_up (VectorLike, optional): direction of the viewport y axis. + Defaults to (0, 0, 1). + look_at (VectorLike, optional): point to look at. + Defaults to None (center of shape). Returns: - ShapeList: filtered object list + tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges """ - if inclusive == (True, True): - objects = filter( + + def extract_edges(compound): + edges = [] # List to store the extracted edges + + # Create a TopExp_Explorer to traverse the sub-shapes of the compound + explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE) + + # Loop through the sub-shapes and extract edges + while explorer.More(): + edge = downcast(explorer.Current()) + edges.append(edge) + explorer.Next() + + return edges + + # Setup the projector + hidden_line_removal = HLRBRep_Algo() + hidden_line_removal.Add(self.wrapped) + + viewport_origin = Vector(viewport_origin) + look_at = Vector(look_at) if look_at else self.center() + projection_dir: Vector = (viewport_origin - look_at).normalized() + viewport_up = Vector(viewport_up).normalized() + camera_coordinate_system = gp_Ax2() + camera_coordinate_system.SetAxis( + gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir()) + ) + camera_coordinate_system.SetYDirection(viewport_up.to_dir()) + projector = HLRAlgo_Projector(camera_coordinate_system) + + hidden_line_removal.Projector(projector) + hidden_line_removal.Update() + hidden_line_removal.Hide() + + hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal) + + # Create the visible edges + visible_edges = [] + for edges in [ + hlr_shapes.VCompound(), + hlr_shapes.Rg1LineVCompound(), + hlr_shapes.OutLineVCompound(), + ]: + if not edges.IsNull(): + visible_edges.extend(extract_edges(downcast(edges))) + + # Create the hidden edges + hidden_edges = [] + for edges in [ + hlr_shapes.HCompound(), + hlr_shapes.OutLineHCompound(), + hlr_shapes.Rg1LineHCompound(), + ]: + if not edges.IsNull(): + hidden_edges.extend(extract_edges(downcast(edges))) + + # Fix the underlying geometry - otherwise we will get segfaults + for edge in visible_edges: + BRepLib.BuildCurves3d_s(edge, TOLERANCE) + for edge in hidden_edges: + BRepLib.BuildCurves3d_s(edge, TOLERANCE) + + # convert to native shape objects + # visible_edges = ShapeList(map(Shape, visible_edges)) + # hidden_edges = ShapeList(map(Shape, hidden_edges)) + visible_edges = ShapeList(map(Edge, visible_edges)) + hidden_edges = ShapeList(map(Edge, hidden_edges)) + + return (visible_edges, hidden_edges) + + +# This TypeVar allows IDEs to see the type of objects within the ShapeList +T = TypeVar("T", bound=Union[Shape, Vector]) +K = TypeVar("K") + + +class ShapePredicate(Protocol): + def __call__(self, shape: Shape) -> bool: + ... + + +class ShapeList(list[T]): + """Subclass of list with custom filter and sort methods appropriate to CAD""" + + @property + def first(self) -> T: + """First element in the ShapeList""" + return self[0] + + @property + def last(self) -> T: + """Last element in the ShapeList""" + return self[-1] + + def filter_by( + self, + filter_by: Union[ShapePredicate, Axis, GeomType], + reverse: bool = False, + tolerance: float = 1e-5, + ) -> ShapeList[T]: + """filter by Axis or GeomType + + Either: + - filter objects of type planar Face or linear Edge by their normal or tangent + (respectively) and sort the results by the given axis, or + - filter the objects by the provided type. Note that not all types apply to all + objects. + + Args: + filter_by (Union[Axis,GeomType]): axis or geom type to filter and possibly sort by + reverse (bool, optional): invert the geom type filter. Defaults to False. + tolerance (float, optional): maximum deviation from axis. Defaults to 1e-5. + + Raises: + ValueError: Invalid filter_by type + + Returns: + ShapeList: filtered list of objects + """ + + # could be moved out maybe? + def axis_parallel_predicate(axis: Axis, tolerance: float): + def pred(shape: Shape): + if isinstance(shape, Face) and shape.geom_type() == "PLANE": + shape_axis = Axis(shape.center(), shape.normal_at(None)) + elif isinstance(shape, Edge) and shape.geom_type() == "LINE": + shape_axis = Axis(shape.position_at(0), shape.tangent_at(0)) + else: + return False + return axis.is_parallel(shape_axis, tolerance) + + return pred + + # convert input to callable predicate + if callable(filter_by): + predicate = filter_by + elif isinstance(filter_by, Axis): + predicate = axis_parallel_predicate(filter_by, tolerance=tolerance) + elif isinstance(filter_by, GeomType): + predicate = lambda o: o.geom_type() == filter_by.name + else: + raise ValueError(f"Unsupported filter_by predicate: {filter_by}") + + # final predicate is negated if `reverse=True` + if reverse: + actual_predicate = lambda shape: not predicate(shape) + else: + actual_predicate = predicate + + return ShapeList(filter(actual_predicate, self)) + + def filter_by_position( + self, + axis: Axis, + minimum: float, + maximum: float, + inclusive: tuple[bool, bool] = (True, True), + ) -> ShapeList[T]: + """filter by position + + Filter and sort objects by the position of their centers along given axis. + min and max values can be inclusive or exclusive depending on the inclusive tuple. + + Args: + axis (Axis): axis to sort by + minimum (float): minimum value + maximum (float): maximum value + inclusive (tuple[bool, bool], optional): include min,max values. + Defaults to (True, True). + + Returns: + ShapeList: filtered object list + """ + if inclusive == (True, True): + objects = filter( lambda o: minimum <= axis.to_plane().to_local_coords(o).center().Z <= maximum, @@ -2791,8 +3205,11 @@ def filter_by_position( return ShapeList(objects).sort_by(axis) def group_by( - self, group_by: Union[Axis, SortBy] = Axis.Z, reverse=False, tol_digits=6 - ) -> list[ShapeList[T]]: + self, + group_by: Union[Callable[[Shape], K], Axis, Edge, Wire, SortBy] = Axis.Z, + reverse=False, + tol_digits=6, + ) -> GroupBy[T, K]: """group by Group objects by provided criteria and then sort the groups according to the criteria. @@ -2805,46 +3222,43 @@ def group_by( round(key, tol_digits) Returns: - list[ShapeList]: sorted list of ShapeLists + GroupBy[K, ShapeList]: sorted list of ShapeLists """ - groups = {} - for obj in self: - if isinstance(group_by, Axis): - key = group_by.to_plane().to_local_coords(obj).center().Z - - elif isinstance(group_by, SortBy): - if group_by == SortBy.LENGTH: - key = obj.length - elif group_by == SortBy.RADIUS: - key = obj.radius - - elif group_by == SortBy.DISTANCE: - key = obj.center().length - - elif group_by == SortBy.AREA: - key = obj.area - - elif group_by == SortBy.VOLUME: - key = obj.volume - - else: - raise ValueError(f"Group by {type(group_by)} unsupported") - - key = round(key, tol_digits) + if isinstance(group_by, Axis): + axis_as_location = group_by.location.inverse() + key_f = lambda obj: round( + # group_by.to_plane().to_local_coords(obj).center().Z, tol_digits + (axis_as_location * Location(obj.center())).position.Z, + tol_digits, + ) + elif isinstance(group_by, (Edge, Wire)): + key_f = lambda obj: round( + group_by.param_at_point(obj.center()), + tol_digits, + ) + elif isinstance(group_by, SortBy): + if group_by == SortBy.LENGTH: + key_f = lambda obj: round(obj.length, tol_digits) + elif group_by == SortBy.RADIUS: + key_f = lambda obj: round(obj.radius, tol_digits) + elif group_by == SortBy.DISTANCE: + key_f = lambda obj: round(obj.center().length, tol_digits) + elif group_by == SortBy.AREA: + key_f = lambda obj: round(obj.area, tol_digits) + elif group_by == SortBy.VOLUME: + key_f = lambda obj: round(obj.volume, tol_digits) + + elif callable(group_by): + key_f = group_by - if groups.get(key) is None: - groups[key] = [obj] - else: - groups[key].append(obj) + else: + raise ValueError(f"Unsupported group_by function: {group_by}") - return [ - ShapeList(el[1]) - for el in sorted(groups.items(), key=lambda o: o[0], reverse=reverse) - ] + return GroupBy(key_f, self, reverse=reverse) def sort_by( - self, sort_by: Union[Axis, SortBy] = Axis.Z, reverse: bool = False + self, sort_by: Union[Axis, Edge, Wire, SortBy] = Axis.Z, reverse: bool = False ) -> ShapeList[T]: """sort by @@ -2852,18 +3266,29 @@ def sort_by( objects. Args: - sort_by (SortBy, optional): sort criteria. Defaults to Axis.Z. + sort_by (SortBy, optional): sort criteria. Defaults to SortBy.Z. reverse (bool, optional): flip order of sort. Defaults to False. Returns: ShapeList: sorted list of objects """ if isinstance(sort_by, Axis): + axis_as_location = sort_by.location.inverse() objects = sorted( self, - key=lambda o: sort_by.to_plane().to_local_coords(o).center().Z, + key=lambda o: (axis_as_location * Location(o.center())).position.Z, reverse=reverse, ) + elif isinstance(sort_by, (Edge, Wire)): + + def u_of_closest_center(o) -> float: + """u-value of closest point between object center and sort_by""" + p1, _p2 = sort_by.closest_points(o.center()) + return sort_by.param_at_point(p1) + + objects = sorted( + self, key=lambda o: u_of_closest_center(o), reverse=reverse + ) elif isinstance(sort_by, SortBy): if sort_by == SortBy.LENGTH: @@ -2922,36 +3347,45 @@ def sort_by_distance( return ShapeList([obj[1] for obj in distances]) def __gt__(self, sort_by: Union[Axis, SortBy] = Axis.Z): - """Sort operator""" + """Sort operator >""" return self.sort_by(sort_by) def __lt__(self, sort_by: Union[Axis, SortBy] = Axis.Z): - """Reverse sort operator""" + """Reverse sort operator <""" return self.sort_by(sort_by, reverse=True) def __rshift__(self, group_by: Union[Axis, SortBy] = Axis.Z): - """Group and select largest group operator""" + """Group and select largest group operator >>""" return self.group_by(group_by)[-1] def __lshift__(self, group_by: Union[Axis, SortBy] = Axis.Z): - """Group and select smallest group operator""" + """Group and select smallest group operator <<""" return self.group_by(group_by)[0] def __or__(self, filter_by: Union[Axis, GeomType] = Axis.Z): - """Filter by axis or geomtype operator""" + """Filter by axis or geomtype operator |""" return self.filter_by(filter_by) def __add__(self, other: ShapeList): - """Combine two ShapeLists together""" + """Combine two ShapeLists together operator +""" return ShapeList(list(self) + list(other)) def __sub__(self, other: ShapeList) -> ShapeList: + """Differences between two ShapeLists operator -""" # hash_other = [hash(o) for o in other] # hash_set = {hash(o): o for o in self if hash(o) not in hash_other} # return ShapeList(hash_set.values()) return ShapeList(set(self) - set(other)) - def __getitem__(self, key): + @overload + def __getitem__(self, key: int) -> T: + ... + + @overload + def __getitem__(self, key: slice) -> ShapeList[T]: + ... + + def __getitem__(self, key: Union[int, slice]) -> Union[T, ShapeList[T]]: """Return slices of ShapeList as ShapeList""" if isinstance(key, slice): return_value = ShapeList(list(self).__getitem__(key)) @@ -2960,6 +3394,46 @@ def __getitem__(self, key): return return_value +class GroupBy: + """Result of a Shape.groupby operation. Groups can be accessed by index or key""" + + def __init__( + self, + key_f: Callable[[Shape], K], + shapelist: Iterable[T], + *, + reverse: bool = False, + ): + # can't be a dict because K may not be hashable + self.key_to_group_index: list[tuple[K, int]] = [] + self.groups: list[ShapeList[T]] = [] + self.key_f = key_f + + for i, (key, shapegroup) in enumerate( + itertools.groupby(sorted(shapelist, key=key_f, reverse=reverse), key=key_f) + ): + self.groups.append(ShapeList(shapegroup)) + self.key_to_group_index.append((key, i)) + + def __iter__(self): + return iter(self.groups) + + def __len__(self): + return len(self.groups) + + def __getitem__(self, key: int): + return self.groups[key] + + def group(self, key: K): + for k, i in self.key_to_group_index: + if key == k: + return self.groups[i] + raise KeyError(key) + + def group_for(self, shape: Shape): + return self.group(self.key_f(shape)) + + class Compound(Shape, Mixin3D): """Compound @@ -3106,7 +3580,8 @@ def do_children_intersect( tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5. Returns: - bool: do the object intersect + tuple[bool, tuple[Shape, Shape], float]: + do the object intersect, intersecting objects, volume of intersection """ children: list[Shape] = list(PreOrderIter(self)) if not include_parent: @@ -3139,7 +3614,7 @@ def do_children_intersect( (children[child_index_pair[0]], children[child_index_pair[1]]), common_volume, ) - return (False, (None, None), None) + return (False, (), 0.0) @classmethod def make_text( @@ -3258,6 +3733,54 @@ def position_face(orig_face: "Face") -> "Face": return text_flat + @classmethod + def make_triad(cls, axes_scale: float) -> Compound: + """The coordinate system triad (X, Y, Z axes)""" + x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0)) + y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0)) + z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale)) + arrow_arc = Edge.make_spline( + [(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)], + [(-1, 0, 0), (-1, 1.5, 0)], + ) + arrow = arrow_arc.fuse(copy.copy(arrow_arc).mirror(Plane.XZ)) + x_label = ( + Compound.make_text( + "X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) + ) + .move(Location(x_axis @ 1)) + .edges() + ) + y_label = ( + Compound.make_text( + "Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) + ) + .rotate(Axis.Z, 90) + .move(Location(y_axis @ 1)) + .edges() + ) + z_label = ( + Compound.make_text( + "Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN) + ) + .rotate(Axis.Y, 90) + .rotate(Axis.X, 90) + .move(Location(z_axis @ 1)) + .edges() + ) + triad = Edge.fuse( + x_axis, + y_axis, + z_axis, + arrow.moved(Location(x_axis @ 1)), + arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)), + arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)), + *x_label, + *y_label, + *z_label, + ) + return triad + def __iter__(self) -> Iterator[Shape]: """ Iterate over subshapes. @@ -3389,11 +3912,11 @@ class Curve(Compound): _dim = 1 def __matmul__(self, position: float): - """Position on curve operator - only works if continuous""" + """Position on curve operator @ - only works if continuous""" return Wire.make_wire(self.edges()).position_at(position) def __mod__(self, position: float): - """Tangent on wire operator - only works if continuous""" + """Tangent on wire operator % - only works if continuous""" return Wire.make_wire(self.edges()).tangent_at(position) def wires(self) -> list[Wire]: @@ -3439,8 +3962,69 @@ def arc_center(self) -> Vector: return return_value + def find_tangent( + self, + angle: float, + plane: Plane = Plane.XY, + ) -> list[float]: + """find_tangent + + Find the parameter values of self where the tangent is equal to angle. + + Args: + angle (float): target angle in degrees + plane (Plane, optional): plane that Edge was constructed on. Defaults to Plane.XY. + + Returns: + list[float]: u values between 0.0 and 1.0 + """ + angle = angle % 360 # angle needs to always be positive 0..360 + + if self.geom_type() == "LINE": + if self.tangent_angle_at(0) == angle: + u_values = [0] + else: + u_values = [] + else: + # Solve this problem geometrically by creating a tangent curve and finding intercepts + periodic = int(self.is_closed()) # if closed don't include end point + tan_pnts = [] + previous_tangent = None + + # When angles go from 360 to 0 a discontinuity is created so add 360 to these + # values and intercept another line + discontinuities = 0.0 + for i in range(101 - periodic): + tangent = self.tangent_angle_at(i / 100) + discontinuities * 360 + if ( + previous_tangent is not None + and abs(previous_tangent - tangent) > 300 + ): + discontinuities = copysign(1.0, previous_tangent - tangent) + tangent += 360 * discontinuities + previous_tangent = tangent + tan_pnts.append((i / 100, tangent)) + + # Generate a first differential curve from the tangent points + tan_curve = Edge.make_spline(tan_pnts) + + # Use the bounding box to find the min and max values + tan_curve_bbox = tan_curve.bounding_box() + min_range = 360 * (floor(tan_curve_bbox.min.Y / 360)) + max_range = 360 * (ceil(tan_curve_bbox.max.Y / 360)) + + # Create a horizontal line for each 360 cycle and intercept it + intercept_pnts = [] + for i in range(min_range, max_range + 1, 360): + line = Edge.make_line((0, angle + i, 0), (100, angle + i, 0)) + intercept_pnts.extend(tan_curve.intersections(plane, line)) + + u_values = [p.X for p in intercept_pnts] + + return u_values + def intersections( - self, plane: Plane, edge: Edge = None, tolerance: float = TOLERANCE + self, plane: Plane, edge: Union[Axis, Edge] = None, tolerance: float = TOLERANCE ) -> list[Vector]: """intersections @@ -3471,18 +4055,33 @@ def intersections( self.param_at(0), self.param_at(1), ) - if edge: - # Check if edge is on the plane - if not all([plane.contains(edge.position_at(i / 7)) for i in range(8)]): - raise ValueError("edge must be a 2D edge on the given plane") - - edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( - edge.wrapped, - edge_surface, - edge_location, - edge.param_at(0), - edge.param_at(1), - ) + if edge is not None: + if isinstance(edge, Axis): + # TODO: make this work for any plane not just Plane.XY + + # Define the origin point and direction vector + ocp_origin = gp_Pnt2d(edge.position.X, edge.position.Y) + ocp_direction = gp_Dir2d(edge.direction.X, edge.direction.Y) + + # Create a line from the origin point and direction vector + ocp_line = Geom2d_Line(gp_Lin2d(ocp_origin, ocp_direction)) + + # Convert the line to a curve + edge_2d_curve = Geom2dAdaptor_Curve(ocp_line).Curve() + elif isinstance(edge, Edge): + # Check if edge is on the plane + if not all([plane.contains(edge.position_at(i / 7)) for i in range(8)]): + raise ValueError("edge must be a 2D edge on the given plane") + edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s( + edge.wrapped, + edge_surface, + edge_location, + edge.param_at(0), + edge.param_at(1), + ) + else: + raise ValueError("edge must be type Edge or Axis") + intersector = Geom2dAPI_InterCurveCurve( self_2d_curve, edge_2d_curve, tolerance ) @@ -3511,7 +4110,7 @@ def trim(self, start: float, end: float) -> Edge: Edge: trimmed edge """ if start >= end: - raise ValueError("start must be less than end") + raise ValueError(f"start ({start}) must be less than end ({end})") new_curve = BRep_Tool.Curve_s( copy.deepcopy(self).wrapped, self.param_at(0), self.param_at(1) @@ -3526,23 +4125,34 @@ def trim(self, start: float, end: float) -> Edge: new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() return Edge(new_edge) - # def overlaps(self, other: Edge, tolerance: float = 1e-4) -> bool: - # """overlaps + def param_at_point(self, point: VectorLike) -> float: + """Parameter at point of Edge""" + + def _project_point_on_curve(curve, gp_pnt) -> float: + projector = GeomAPI_ProjectPointOnCurve(gp_pnt, curve) + parameter = projector.LowerDistanceParameter() + return parameter - # Check to determine if self and other overlap + point = Vector(point) - # Args: - # other (Edge): edge to check against - # tolerance (float, optional): min distance between edges. Defaults to 1e-4. + if self.distance_to(point) > TOLERANCE: + raise ValueError(f"point ({point}) is not on edge") - # Returns: - # bool: edges are within tolerance of each other - # """ - # analyzer = ShapeAnalysis_Edge() - # return analyzer.CheckOverlapping( - # # self.wrapped, other.wrapped, tolerance, 2*tolerance - # self.wrapped, other.wrapped, tolerance, domain_distance - # ) + # Get the extreme of the parameter values for this Edge/Wire + curve = BRep_Tool.Curve_s(self.wrapped, 0, 1) + param_min = _project_point_on_curve(curve, self.position_at(0).to_pnt()) + param_value = _project_point_on_curve(curve, point.to_pnt()) + if self.is_closed(): + u_value = (param_value - param_min) / (self.param_at(1) - self.param_at(0)) + else: + param_max = _project_point_on_curve(curve, self.position_at(1).to_pnt()) + u_value = (param_value - param_min) / (param_max - param_min) + + # if not (-TOLERANCE <= u_value <= 1.0 + TOLERANCE): + # raise RuntimeError( + # f"param_at_point returned {u_value}, which is invalid {param_value=}, {param_min=}, {param_max=}" + # ) + return u_value @classmethod def make_bezier(cls, *cntl_pnts: VectorLike, weights: list[float] = None) -> Edge: @@ -3620,12 +4230,14 @@ def make_circle( if start_angle == end_angle: # full circle case return_value = cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge()) else: # arc case - circle_geom = GC_MakeArcOfCircle( - circle_gp, - start_angle * DEG2RAD, - end_angle * DEG2RAD, - angular_direction == AngularDirection.COUNTER_CLOCKWISE, - ).Value() + ccw = angular_direction == AngularDirection.COUNTER_CLOCKWISE + if ccw: + start = radians(start_angle) + end = radians(end_angle) + else: + start = radians(end_angle) + end = radians(start_angle) + circle_geom = GC_MakeArcOfCircle(circle_gp, start, end, ccw).Value() return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) return return_value @@ -3998,7 +4610,7 @@ class Face(Shape): @property def length(self) -> float: - """experimental length calculation""" + """length of planar face""" result = None if self.geom_type() == "PLANE": # Reposition on Plane.XY @@ -4009,7 +4621,7 @@ def length(self) -> float: @property def width(self) -> float: - """experimental width calculation""" + """width of planar face""" result = None if self.geom_type() == "PLANE": # Reposition on Plane.XY @@ -4020,7 +4632,7 @@ def width(self) -> float: @property def geometry(self) -> str: - """experimental geometry type""" + """geometry of planar face""" result = None if self.geom_type() == "PLANE": flat_face = Plane(self).to_local_coords(self) @@ -4065,7 +4677,7 @@ def _uv_bounds(self) -> Tuple[float, float, float, float]: return BRepTools.UVBounds_s(self.wrapped) def __neg__(self) -> Face: - """Return a copy of self with the normal reversed""" + """Reverse normal operator -""" new_face = copy.deepcopy(self) new_face.wrapped = downcast(self.wrapped.Complemented()) return new_face @@ -4105,9 +4717,33 @@ def normal_at(self, surface_point: VectorLike = None) -> Vector: normal = gp_Vec() BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) - return Vector(normal) + return Vector(normal).normalized() + + def position_at(self, u: float, v: float) -> Vector: + """position_at + + Computes a point on the Face given u, v coordinates. + + Args: + u (float): the horizontal coordinate in the parameter space of the Face, + between 0.0 and 1.0 + v (float): the vertical coordinate in the parameter space of the Face, + between 0.0 and 1.0 + + Returns: + Vector: point on Face + """ + u_val0, u_val1, v_val0, v_val1 = self._uv_bounds() + u_val = u_val0 + u * (u_val1 - u_val0) + v_val = v_val0 + v * (v_val1 - v_val0) + + gp_pnt = gp_Pnt() + normal = gp_Vec() + BRepGProp_Face(self.wrapped).Normal(u_val, v_val, gp_pnt, normal) + + return Vector(gp_pnt) - def center(self, center_of=CenterOf.GEOMETRY): + def center(self, center_of=CenterOf.GEOMETRY) -> Vector: """Center of Face Return the center based on center_of @@ -4143,11 +4779,11 @@ def outer_wire(self) -> Wire: """Extract the perimeter wire from this Face""" return Wire(BRepTools.OuterWire_s(self.wrapped)) - def inner_wires(self) -> list[Wire]: + def inner_wires(self) -> ShapeList[Wire]: """Extract the inner or hole wires from this Face""" outer = self.outer_wire() - return [w for w in self.wires() if not w.is_same(outer)] + return ShapeList([w for w in self.wires() if not w.is_same(outer)]) @classmethod def make_rect(cls, width: float, height: float, plane: Plane = Plane.XY) -> Face: @@ -4314,6 +4950,15 @@ def sew_faces(cls, faces: Iterable[Face]) -> list[ShapeList[Face]]: return sewn_faces + @classmethod + def sweep(cls, profile: Edge, path: Union[Edge, Wire]) -> Face: + """Sweep a 1D profile along a 1D path""" + if isinstance(path, Edge): + path = Wire.make_wire([path]) + pipe_sweep = BRepOffsetAPI_MakePipe(path.wrapped, profile.wrapped) + pipe_sweep.Build() + return Face(pipe_sweep.Shape()) + @classmethod def make_surface_from_array_of_points( cls, @@ -4367,9 +5012,9 @@ def make_surface_from_array_of_points( @classmethod def make_surface( cls, - exterior: Union[Wire, list[Edge]], - surface_points: list[VectorLike] = None, - interior_wires: list[Wire] = None, + exterior: Union[Wire, Iterable[Edge]], + surface_points: Iterable[VectorLike] = None, + interior_wires: Iterable[Wire] = None, ) -> Face: """Create Non-Planar Face @@ -4420,15 +5065,25 @@ def make_surface( ) if isinstance(exterior, Wire): outside_edges = exterior.edges() - else: + elif isinstance(exterior, Iterable) and all( + [isinstance(o, Edge) for o in exterior] + ): outside_edges = exterior + else: + raise ValueError("exterior must be a Wire or list of Edges") + for edge in outside_edges: surface.Add(edge.wrapped, GeomAbs_C0) try: surface.Build() surface_face = Face(surface.Shape()) - except (StdFail_NotDone, Standard_NoSuchObject) as err: + except ( + Standard_Failure, + StdFail_NotDone, + Standard_NoSuchObject, + Standard_ConstructionError, + ) as err: raise RuntimeError( "Error building non-planar face with provided exterior" ) from err @@ -4602,14 +5257,27 @@ def project_to_shape( max_dimension = ( Compound.make_compound([self, target_object]).bounding_box().diagonal ) - face_extruded = Solid.extrude_linear( - self, Vector(direction) * max_dimension, taper=taper - ) + if taper == 0: + face_extruded = Solid.extrude(self, Vector(direction) * max_dimension) + else: + face_extruded = Solid.extrude_taper( + self, Vector(direction) * max_dimension, taper=taper + ) + intersected_faces = ShapeList() for target_face in target_object.faces(): intersected_faces.extend(face_extruded.intersect(target_face).faces()) - return intersected_faces.sort_by(Axis(self.center(), direction)) + # intersected faces may be fragmented so we'll put them back together + sewed_face_list = Face.sew_faces(intersected_faces) + sewed_faces = ShapeList() + for face_group in sewed_face_list: + if len(face_group) > 1: + sewed_faces.append(face_group.pop(0).fuse(*face_group).clean()) + else: + sewed_faces.append(face_group[0]) + + return sewed_faces.sort_by(Axis(self.center(), direction)) def make_holes(self, interior_wires: list[Wire]) -> Face: """Make Holes in Face @@ -4939,60 +5607,77 @@ def make_sphere( ) @classmethod - def extrude_linear( - cls, - section: Union[Face, Wire], - normal: VectorLike, - inner_wires: list[Wire] = None, - taper: float = 0, + def extrude_taper( + cls, profile: Face, direction: VectorLike, taper: float, flip_inner: bool = True ) -> Solid: - """Extrude a cross section + """Extrude a cross section with a taper Extrude a cross section into a prismatic solid in the provided direction. - The wires must not intersect. - - Extruding wires is very non-trivial. Nested wires imply very different geometry, and - there are many geometries that are invalid. In general, the following conditions - must be met: - * all wires must be closed - * there cannot be any intersecting or self-intersecting wires - * wires must be listed from outside in - * more than one levels of nesting is not supported reliably + Note that two difference algorithms are used. If direction aligns with + the profile normal (which must be positive), the taper is positive and the profile + contains no holes the OCP LocOpe_DPrism algorithm is used as it generates the most + accurate results. Otherwise, a loft is created between the profile and the profile + with a 2D offset set at the appropriate direction. Args: - section (Union[Face,Wire]): cross section + section (Face]): cross section normal (VectorLike): a vector along which to extrude the wires. The length of the vector controls the length of the extrusion. - inner_wires (list[Wire], optional): holes - only used if section is a Wire. - Defaults to None. - taper (float, optional): taper angle. Defaults to 0. + taper (float): taper angle in degrees. + flip_inner (bool, optional): outer and inner geometry have opposite tapers to + allow for part extraction when injection molding. Returns: Solid: extruded cross section """ - inner_wires = inner_wires if inner_wires else [] - normal = Vector(normal) - if isinstance(section, Wire): - # TODO: Should the normal of this face be forced to align with the extrusion normal? - section_face = Face.make_from_wires(section, inner_wires) - else: - section_face = section - if taper == 0: - prism_builder: Any = BRepPrimAPI_MakePrism( - section_face.wrapped, normal.wrapped, True + direction = Vector(direction) + + if ( + direction.normalized() == profile.normal_at() + and Plane(profile).z_dir.Z > 0 + and taper > 0 + and not profile.inner_wires() + ): + prism_builder = LocOpe_DPrism( + profile.wrapped, + direction.length / cos(radians(taper)), + radians(taper), ) + new_solid = Solid(prism_builder.Shape()) else: - face_normal = section_face.normal_at() - direction = 1 if normal.get_angle(face_normal) < 90 else -1 - prism_builder = LocOpe_DPrism( - section_face.wrapped, - direction * normal.length, - direction * taper * DEG2RAD, + # Determine the offset to get the taper + offset_amt = -direction.length * tan(radians(taper)) + + outer = profile.outer_wire() + local_outer: Wire = Plane(profile).to_local_coords(outer) + local_taper_outer = local_outer.offset_2d( + offset_amt, kind=Kind.INTERSECTION ) + taper_outer = Plane(profile).from_local_coords(local_taper_outer) + taper_outer.move(Location(direction)) + + profile_wires = [profile.outer_wire()] + profile.inner_wires() + + taper_wires = [] + for i, wire in enumerate(profile_wires): + flip = -1 if i > 0 and flip_inner else 1 + local: Wire = Plane(profile).to_local_coords(wire) + local_taper = local.offset_2d(flip * offset_amt, kind=Kind.INTERSECTION) + taper = Plane(profile).from_local_coords(local_taper) + taper.move(Location(direction)) + taper_wires.append(taper) + + solids = [ + Solid.make_loft([p, t]) for p, t in zip(profile_wires, taper_wires) + ] + if len(solids) > 1: + new_solid = solids[0].cut(*solids[1:]) + else: + new_solid = solids[0] - return cls(prism_builder.Shape()) + return new_solid @classmethod def extrude_linear_with_rotation( @@ -5019,7 +5704,7 @@ def extrude_linear_with_rotation( Returns: Solid: extruded object """ - # Though the signature may appear to be similar enough to extrude_linear to merit + # Though the signature may appear to be similar enough to extrude to merit # combining them, the construction methods used here are different enough that they # should be separate. @@ -5108,6 +5793,9 @@ def extrude_until( Union[Compound, Solid]: extruded Face """ direction = Vector(direction) + if until in [Until.PREVIOUS, Until.FIRST]: + direction *= -1 + until = Until.NEXT if until == Until.PREVIOUS else Until.LAST max_dimension = ( Compound.make_compound([section, target_object]).bounding_box().diagonal @@ -5119,14 +5807,21 @@ def extrude_until( ) direction_axis = Axis(section.center(), clipping_direction) # Create a linear extrusion to start - extrusion = Solid.extrude_linear(section, direction * max_dimension) + extrusion = Solid.extrude(section, direction * max_dimension) # Project section onto the shape to generate faces that will clip the extrusion # and exclude the planar faces normal to the direction of extrusion and these # will have no volume when extruded + faces = [] + for f in section.project_to_shape(target_object, direction): + if isinstance(f, Face): + faces.append(f) + else: + faces += f.faces() + clip_faces = [ f - for f in section.project_to_shape(target_object, direction) + for f in faces if not (f.geom_type() == "PLANE" and f.normal_at().dot(direction) == 0.0) ] if not clip_faces: @@ -5134,8 +5829,9 @@ def extrude_until( # Create the objects that will clip the linear extrusion clipping_objects = [ - Solid.extrude_linear(f, clipping_direction).fix() for f in clip_faces + Solid.extrude(f, clipping_direction).fix() for f in clip_faces ] + clipping_objects = [o for o in clipping_objects if o.volume > 1e-9] if until == Until.NEXT: extrusion = extrusion.cut(target_object) @@ -5540,19 +6236,17 @@ def to_wire(self) -> Wire: @classmethod def combine( cls, wires: Iterable[Union[Wire, Edge]], tol: float = 1e-9 - ) -> list[Wire]: - """Attempt to combine a list of wires and edges into a new wire. + ) -> ShapeList[Wire]: + """combine + + Combine a list of wires and edges into a list of Wires. Args: - cls: param list_of_wires: - tol: default 1e-9 - wires: Iterable[Union[Wire: - Edge]]: - tol: float: (Default value = 1e-9) + wires (Iterable[Union[Wire, Edge]]): unsorted + tol (float, optional): tolerance. Defaults to 1e-9. Returns: - list[Wire] - + ShapeList[Wire]: Wires """ edges_in = TopTools_HSequenceOfShape() @@ -5563,7 +6257,144 @@ def combine( ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out) - return [cls(wire) for wire in wires_out] + return ShapeList(cls(wire) for wire in wires_out) + + def fix_degenerate_edges(self, precision: float) -> Wire: + """fix_degenerate_edges + + Fix a Wire that contains degenerate (very small) edges + + Args: + precision (float): minimum value edge length + + Returns: + Wire: fixed wire + """ + sf_w = ShapeFix_Wireframe(self.wrapped) + sf_w.SetPrecision(precision) + sf_w.SetMaxTolerance(1e-6) + sf_w.FixSmallEdges() + sf_w.FixWireGaps() + return Wire(downcast(sf_w.Shape())) + + def param_at_point(self, point: VectorLike) -> float: + """Parameter at point on Wire""" + + # OCP doesn't support this so this algoritm finds the edge that contains the + # point, finds the u value/fractional distance of the point on that edge and + # sums up the length of the edges from the start to the edge with the point. + + wire_length = self.length + edge_list = self.edges() + target = self.position_at(0) # To start, find the edge at the beginning + distance = 0.0 # distance along wire + found = False + + while edge_list: + # Find the edge closest to the target + edge = sorted(edge_list, key=lambda e: e.distance_to(target))[0] + edge_list.pop(edge_list.index(edge)) + + # The edge might be flipped requiring the u value to be reversed + edge_p0 = edge.position_at(0) + edge_p1 = edge.position_at(1) + flipped = (target - edge_p0).length > (target - edge_p1).length + + # Set the next start to "end" of the current edge + target = edge_p0 if flipped else edge_p1 + + # If this edge contain the point, get a fractional distance - otherwise the whole + if edge.distance_to(point) <= TOLERANCE: + found = True + u_value = edge.param_at_point(point) + if flipped: + distance += (1 - u_value) * edge.length + else: + distance += u_value * edge.length + break + else: + distance += edge.length + + if not found: + raise ValueError(f"{point} not on wire") + + return distance / wire_length + + def trim(self, start: float, end: float) -> Wire: + """trim + + Create a new wire by keeping only the section between start and end. + + Args: + start (float): 0.0 <= start < 1.0 + end (float): 0.0 < end <= 1.0 + + Raises: + ValueError: start >= end + + Returns: + Wire: trimmed wire + """ + if start >= end: + raise ValueError("start must be less than end") + + trim_start_point = self.position_at(start) + trim_end_point = self.position_at(end) + + # Get all the edges + modified_edges: list[Edge] = [] + original_edges: list[Edge] = [] + for edge in self.edges(): + # Is edge flipped + flipped = self.param_at_point(edge.position_at(0)) > self.param_at_point( + edge.position_at(1) + ) + # Does this edge contain the start/end points + contains_start = edge.distance_to(trim_start_point) <= TOLERANCE + contains_end = edge.distance_to(trim_end_point) <= TOLERANCE + + # Trim edges containing start or end points + degenerate = False + if contains_start: + u = edge.param_at_point(trim_start_point) + if not flipped: + degenerate = u == 1.0 + if not degenerate: + edge = edge.trim(u, 1.0) + elif flipped: + degenerate = u == 0.0 + if not degenerate: + edge = edge.trim(0.0, u) + if contains_end: + u = edge.param_at_point(trim_end_point) + if not flipped: + degenerate = u == 0.0 + if not degenerate: + edge = edge.trim(0.0, u) + elif flipped: + degenerate = u == 1.0 + if not degenerate: + edge = edge.trim(u, 1.0) + if not degenerate: + if contains_start or contains_end: + modified_edges.append(edge) + else: + original_edges.append(edge) + + # Select the wire containing the start and end points + wire_segments = edges_to_wires(modified_edges + original_edges) + trimed_wire = filter( + lambda w: all( + [ + w.distance_to(p) <= TOLERANCE + for p in [trim_start_point, trim_end_point] + ] + ), + wire_segments, + ) + if not trimed_wire: + raise RuntimeError("Invalid trim result") + return next(trimed_wire) @classmethod def make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> Wire: @@ -5612,10 +6443,10 @@ def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge: edges = placed_edges wire_builder = BRepBuilderAPI_MakeWire() + combined_edges = TopTools_ListOfShape() for edge in edges: - wire_builder.Add(edge.wrapped) - if sequenced and wire_builder.Error() == BRepBuilderAPI_DisconnectedWire: - raise ValueError("Edges are disconnected") + combined_edges.Append(edge.wrapped) + wire_builder.Add(combined_edges) wire_builder.Build() if not wire_builder.IsDone(): @@ -5623,6 +6454,8 @@ def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge: warnings.warn("Wire is non manifold") elif wire_builder.Error() == BRepBuilderAPI_EmptyWire: raise RuntimeError("Wire is empty") + elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire: + raise ValueError("Edges are disconnected") return cls(wire_builder.Wire()) @@ -5782,38 +6615,6 @@ def stitch(self, other: Wire) -> Wire: return self.__class__(wire_builder.Wire()) - def offset_2d(self, distance: float, kind: Kind = Kind.ARC) -> list[Wire]: - """Wire Offset - - Offsets a planar wire - - Args: - distance (float): distance from wire to offset - kind (Kind, optional): offset corner transition. Defaults to Kind.ARC. - - Returns: - list[Wire]: offset wires - """ - kind_dict = { - Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc, - Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection, - Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent, - } - - offset = BRepOffsetAPI_MakeOffset() - offset.Init(kind_dict[kind]) - offset.AddWire(self.wrapped) - offset.Perform(distance) - - obj = downcast(offset.Shape()) - - if isinstance(obj, TopoDS_Compound): - return_value = [self.__class__(el.wrapped) for el in Compound(obj)] - else: - return_value = [self.__class__(obj)] - - return return_value - def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Wire: """fillet_2d @@ -6088,571 +6889,6 @@ def project_to_shape( return output_wires -class DXF: - """DXF file import and export functionality""" - - CURVE_TOLERANCE = 1e-9 - - @staticmethod - def _dxf_line(edge: Edge, msp: ezdxf.layouts.Modelspace, _plane: Plane): - msp.add_line( - edge.start_point().to_tuple(), - edge.end_point().to_tuple(), - ) - - @staticmethod - def _dxf_circle(edge: Edge, msp: ezdxf.layouts.Modelspace, _plane: Plane): - geom = edge._geom_adaptor() - circ = geom.Circle() - - radius = circ.Radius() - center_location = circ.Location() - - circle_direction_y = circ.YAxis().Direction() - circle_direction_z = circ.Axis().Direction() - - phi = circle_direction_y.AngleWithRef(gp_Dir(0, 1, 0), circle_direction_z) - - if circle_direction_z.XYZ().Z() > 0: - angle1 = degrees(geom.FirstParameter() - phi) - angle2 = degrees(geom.LastParameter() - phi) - else: - angle1 = -degrees(geom.LastParameter() - phi) + 180 - angle2 = -degrees(geom.FirstParameter() - phi) + 180 - - if edge.is_closed(): - msp.add_circle( - (center_location.X(), center_location.Y(), center_location.Z()), radius - ) - else: - msp.add_arc( - (center_location.X(), center_location.Y(), center_location.Z()), - radius, - angle1, - angle2, - ) - - @staticmethod - def _dxf_ellipse(edge: Edge, msp: ezdxf.layouts.Modelspace, _plane: Plane): - geom = edge._geom_adaptor() - ellipse = geom.Ellipse() - - radius_minor = ellipse.MinorRadius() - radius_major = ellipse.MajorRadius() - - center_location = ellipse.Location() - ellipse_direction_x = ellipse.XAxis().Direction() - xax = radius_major * ellipse_direction_x.XYZ() - - msp.add_ellipse( - (center_location.X(), center_location.Y(), center_location.Z()), - (xax.X(), xax.Y(), xax.Z()), - radius_minor / radius_major, - geom.FirstParameter(), - geom.LastParameter(), - ) - - @staticmethod - def _dxf_spline(edge: Edge, msp: ezdxf.layouts.Modelspace, plane: Plane): - adaptor = edge._geom_adaptor() - curve = GeomConvert.CurveToBSplineCurve_s(adaptor.Curve().Curve()) - - spline = GeomConvert.SplitBSplineCurve_s( - curve, - adaptor.FirstParameter(), - adaptor.LastParameter(), - DXF.CURVE_TOLERANCE, - ) - - # need to apply the transform on the geometry level - spline.Transform(plane.forward_transform.wrapped.Trsf()) - - order = spline.Degree() + 1 - knots = list(spline.KnotSequence()) - poles = [(p.X(), p.Y(), p.Z()) for p in spline.Poles()] - weights = ( - [spline.Weight(i) for i in range(1, spline.NbPoles() + 1)] - if spline.IsRational() - else None - ) - - if spline.IsPeriodic(): - pad = spline.NbKnots() - spline.LastUKnotIndex() - poles += poles[:pad] - - dxf_spline = ezdxf.math.BSpline(poles, order, knots, weights) - - msp.add_spline().apply_construction_tool(dxf_spline) - - -class SVG: - """SVG file import and export functionality""" - - _DISCRETIZATION_TOLERANCE = 1e-3 - - _SVG_TEMPLATE = """ - - - - - %(hidden_content)s - - - - - %(visible_content)s - - - - """ - - _PATHTEMPLATE = '\t\t\t\n' - - @classmethod - def make_svg_edge(cls, edge: Edge): - """Creates an SVG edge from a OCCT edge""" - - memory_file = StringIO.StringIO() - - curve = edge._geom_adaptor() # adapt the edge into curve - start = curve.FirstParameter() - end = curve.LastParameter() - - points = GCPnts_QuasiUniformDeflection( - curve, SVG._DISCRETIZATION_TOLERANCE, start, end - ) - - if points.IsDone(): - point_it = (points.Value(i + 1) for i in range(points.NbPoints())) - - gp_pnt = next(point_it) - memory_file.write(f"M{gp_pnt.X()},{gp_pnt.Y()} ") - - for gp_pnt in point_it: - memory_file.write(f"L{gp_pnt.X()},{gp_pnt.Y()} ") - - return memory_file.getvalue() - - @classmethod - def get_paths(cls, visible_shapes: list[Shape], hidden_shapes: list[Shape]): - """Collects the visible and hidden edges from the object""" - - hidden_paths = [] - visible_paths = [] - - for shape in visible_shapes: - for edge in shape.edges(): - visible_paths.append(SVG.make_svg_edge(edge)) - - for shape in hidden_shapes: - for edge in shape.edges(): - hidden_paths.append(SVG.make_svg_edge(edge)) - - return (hidden_paths, visible_paths) - - @classmethod - def axes(cls, axes_scale: float) -> Compound: - """The X, Y, Z axis object""" - x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0)) - y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0)) - z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale)) - arrow_arc = Edge.make_spline( - [(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)], - [(-1, 0, 0), (-1, 1.5, 0)], - ) - arrow = arrow_arc.fuse(copy.copy(arrow_arc).mirror(Plane.XZ)) - x_label = ( - Compound.make_text( - "X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) - ) - .move(Location(x_axis @ 1)) - .edges() - ) - y_label = ( - Compound.make_text( - "Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER) - ) - .rotate(Axis.Z, 90) - .move(Location(y_axis @ 1)) - .edges() - ) - z_label = ( - Compound.make_text( - "Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN) - ) - .rotate(Axis.Y, 90) - .rotate(Axis.X, 90) - .move(Location(z_axis @ 1)) - .edges() - ) - axes = Edge.fuse( - x_axis, - y_axis, - z_axis, - arrow.moved(Location(x_axis @ 1)), - arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)), - arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)), - *x_label, - *y_label, - *z_label, - ) - return axes - - @classmethod - def get_svg( - cls, - shape: Shape, - viewport_origin: VectorLike, - viewport_up: VectorLike = (0, 0, 1), - look_at: VectorLike = None, - svg_opts: dict = None, - ) -> str: - """get_svg - - Translate a shape to SVG text. - - Args: - shape (Shape): target object - viewport_origin (VectorLike): location of viewport - viewport_up (VectorLike, optional): direction of the viewport y axis. - Defaults to (0, 0, 1). - look_at (VectorLike, optional): point to look at. - Defaults to None (center of shape). - - SVG Options - e.g. svg_opts = {"pixel_scale":50}: - - Other Parameters: - width (int): Viewport width in pixels. Defaults to 240. - height (int): Viewport width in pixels. Defaults to 240. - pixel_scale (float): Pixels per CAD unit. - Defaults to None (calculated based on width & height). - units (str): SVG document units. Defaults to "mm". - margin_left (int): Defaults to 20. - margin_top (int): Defaults to 20. - show_axes (bool): Display an axis indicator. Defaults to True. - axes_scale (float): Length of axis indicator in global units. - Defaults to 1.0. - stroke_width (float): Width of visible edges. - Defaults to None (calculated based on unit_scale). - stroke_color (tuple[int]): Visible stroke color. Defaults to RGB(0, 0, 0). - hidden_color (tuple[int]): Hidden stroke color. Defaults to RBG(160, 160, 160). - show_hidden (bool): Display hidden lines. Defaults to True. - - Returns: - str: SVG text string - """ - # Available options and their defaults - defaults = { - "width": 240, - "height": 240, - "pixel_scale": None, - "units": "mm", - "margin_left": 20, - "margin_top": 20, - "show_axes": True, - "axes_scale": 1.0, - "stroke_width": None, # calculated based on unit_scale - "stroke_color": (0, 0, 0), # RGB 0-255 - "hidden_color": (160, 160, 160), # RGB 0-255 - "show_hidden": True, - } - - if svg_opts: - defaults.update(svg_opts) - - width = float(defaults["width"]) - height = float(defaults["height"]) - margin_left = float(defaults["margin_left"]) - margin_top = float(defaults["margin_top"]) - show_axes = bool(defaults["show_axes"]) - stroke_color = tuple(defaults["stroke_color"]) - hidden_color = tuple(defaults["hidden_color"]) - show_hidden = bool(defaults["show_hidden"]) - - # Setup the projector - hidden_line_removal = HLRBRep_Algo() - hidden_line_removal.Add(shape.wrapped) - if show_axes: - hidden_line_removal.Add(SVG.axes(defaults["axes_scale"]).wrapped) - - viewport_origin = Vector(viewport_origin) - look_at = Vector(look_at) if look_at else shape.center() - projection_dir: Vector = (viewport_origin - look_at).normalized() - viewport_up = Vector(viewport_up).normalized() - camera_coordinate_system = gp_Ax2() - camera_coordinate_system.SetAxis( - gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir()) - ) - camera_coordinate_system.SetYDirection(viewport_up.to_dir()) - projector = HLRAlgo_Projector(camera_coordinate_system) - - hidden_line_removal.Projector(projector) - hidden_line_removal.Update() - hidden_line_removal.Hide() - - hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal) - - # Create the visible edges - visible_edges = [] - visible_sharp_edges = hlr_shapes.VCompound() - if not visible_sharp_edges.IsNull(): - visible_edges.append(visible_sharp_edges) - - visible_smooth_edges = hlr_shapes.Rg1LineVCompound() - if not visible_smooth_edges.IsNull(): - visible_edges.append(visible_smooth_edges) - - visible_contour_edges = hlr_shapes.OutLineVCompound() - if not visible_contour_edges.IsNull(): - visible_edges.append(visible_contour_edges) - - # print("Visible Edges") - # for edge_compound in visible_edges: - # for edge in Compound(edge_compound).edges(): - # print(type(edge), edge.geom_type()) - # topo_abs: Any = geom_LUT[shapetype(edge)] - # print(downcast(edge).GetType()) - # geom_LUT_EDGE[topo_abs(self.wrapped).GetType()] - - # Create the hidden edges - hidden_edges = [] - hidden_sharp_edges = hlr_shapes.HCompound() - if not hidden_sharp_edges.IsNull(): - hidden_edges.append(hidden_sharp_edges) - - hidden_contour_edges = hlr_shapes.OutLineHCompound() - if not hidden_contour_edges.IsNull(): - hidden_edges.append(hidden_contour_edges) - - # Fix the underlying geometry - otherwise we will get segfaults - for edge in visible_edges: - BRepLib.BuildCurves3d_s(edge, TOLERANCE) - for edge in hidden_edges: - BRepLib.BuildCurves3d_s(edge, TOLERANCE) - - # convert to native shape objects - visible_edges = list(map(Shape, visible_edges)) - hidden_edges = list(map(Shape, hidden_edges)) - (hidden_paths, visible_paths) = SVG.get_paths(visible_edges, hidden_edges) - - # get bounding box -- these are all in 2D space - b_box = Compound.make_compound(hidden_edges + visible_edges).bounding_box() - # width pixels for x, height pixels for y - if defaults["pixel_scale"]: - unit_scale = defaults["pixel_scale"] - width = int(unit_scale * b_box.size.X + 2 * defaults["margin_left"]) - height = int(unit_scale * b_box.size.Y + 2 * defaults["margin_left"]) - else: - unit_scale = min(width / b_box.size.X * 0.75, height / b_box.size.Y * 0.75) - # compute amount to translate-- move the top left into view - (x_translate, y_translate) = ( - (0 - b_box.min.X) + margin_left / unit_scale, - (0 - b_box.max.Y) - margin_top / unit_scale, - ) - - # If the user did not specify a stroke width, calculate it based on the unit scale - if defaults["stroke_width"]: - stroke_width = float(defaults["stroke_width"]) - else: - stroke_width = 1.0 / unit_scale - - # compute paths - hidden_content = "" - - # Prevent hidden paths from being added if the user disabled them - if show_hidden: - for paths in hidden_paths: - hidden_content += SVG._PATHTEMPLATE % paths - - visible_content = "" - for paths in visible_paths: - visible_content += SVG._PATHTEMPLATE % paths - - svg = SVG._SVG_TEMPLATE % ( - { - "unit_scale": str(unit_scale), - "stroke_width": str(stroke_width), - "stroke_color": ",".join([str(x) for x in stroke_color]), - "hidden_color": ",".join([str(x) for x in hidden_color]), - "hidden_content": hidden_content, - "visible_content": visible_content, - "x_translate": str(x_translate), - "y_translate": str(y_translate), - "width": str(width), - "height": str(height), - "text_box_y": str(height - 30), - "uom": defaults["units"], - } - ) - - return svg - - -class ThreeMF: - """3MF exporter""" - - class ContentTypes: - MODEL = "application/vnd.ms-package.3dmanufacturing-3dmodel+xml" - RELATION = "application/vnd.openxmlformats-package.relationships+xml" - - class Schemas: - CONTENT_TYPES = "http://schemas.openxmlformats.org/package/2006/content-types" - RELATION = "http://schemas.openxmlformats.org/package/2006/relationships" - CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02" - MODEL = "http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel" - - def __init__( - self, - shape: Shape, - tolerance: float, - angular_tolerance: float, - unit: Unit = Unit.MILLIMETER, - ): - """ - Initialize the writer. - Used to write the given Shape to a 3MF file. - """ - self.unit = unit.name.lower() - - if isinstance(shape, Compound): - shapes = list(shape) - else: - shapes = [shape] - - tessellations = [s.tessellate(tolerance, angular_tolerance) for s in shapes] - # Remove shapes that did not tesselate - self.tessellations = [t for t in tessellations if all(t)] - - def write_3mf(self, file_name: str): - """ - Write to the given file. - """ - - try: - import zlib - - compression = ZIP_DEFLATED - except ImportError: - compression = ZIP_STORED - - with ZipFile(file_name, "w", compression) as zip_file: - zip_file.writestr("_rels/.rels", self._write_relationships()) - zip_file.writestr("[Content_Types].xml", self._write_content_types()) - zip_file.writestr("3D/3dmodel.model", self._write_3d()) - - def _write_3d(self) -> str: - no_meshes = len(self.tessellations) - - model = ET.Element( - "model", - { - "xml:lang": "en-US", - "xmlns": ThreeMF.Schemas.CORE, - }, - unit=self.unit, - ) - - # Add meta data - ET.SubElement( - model, "metadata", name="Application" - ).text = "Build123d 3MF Exporter" - ET.SubElement( - model, "metadata", name="CreationDate" - ).text = datetime.now().isoformat() - - resources = ET.SubElement(model, "resources") - - # Add all meshes to resources - for i, tessellation in enumerate(self.tessellations): - self._add_mesh(resources, str(i), tessellation) - - # Create a component of all meshes - comp_object = ET.SubElement( - resources, - "object", - id=str(no_meshes), - name="Build123d Component", - type="model", - ) - components = ET.SubElement(comp_object, "components") - - # Add all meshes to the component - for i in range(no_meshes): - ET.SubElement( - components, - "component", - objectid=str(i), - ) - - # Add the component to the build - build = ET.SubElement(model, "build") - ET.SubElement(build, "item", objectid=str(no_meshes)) - - return ET.tostring(model, xml_declaration=True, encoding="utf-8") - - def _add_mesh( - self, - to: ET.Element, - id: str, - tessellation: tuple[list[Vector], list[tuple[int, int, int]]], - ): - obj = ET.SubElement( - to, "object", id=id, name=f"CadQuery Shape {id}", type="model" - ) - mesh = ET.SubElement(obj, "mesh") - - # add vertices - vertices = ET.SubElement(mesh, "vertices") - for vert in tessellation[0]: - ET.SubElement( - vertices, "vertex", x=str(vert.X), y=str(vert.Y), z=str(vert.Z) - ) - - # add triangles - volume = ET.SubElement(mesh, "triangles") - for tess in tessellation[1]: - ET.SubElement( - volume, "triangle", v1=str(tess[0]), v2=str(tess[1]), v3=str(tess[2]) - ) - - def _write_content_types(self) -> str: - root = ET.Element("Types") - root.set("xmlns", ThreeMF.Schemas.CONTENT_TYPES) - ET.SubElement( - root, - "Override", - PartName="/3D/3dmodel.model", - ContentType=ThreeMF.ContentTypes.MODEL, - ) - ET.SubElement( - root, - "Override", - PartName="/_rels/.rels", - ContentType=ThreeMF.ContentTypes.RELATION, - ) - - return ET.tostring(root, xml_declaration=True, encoding="utf-8") - - def _write_relationships(self) -> str: - root = ET.Element("Relationships") - root.set("xmlns", ThreeMF.Schemas.RELATION) - ET.SubElement( - root, - "Relationship", - Target="/3D/3dmodel.model", - Id="rel-1", - Type=ThreeMF.Schemas.MODEL, - TargetMode="Internal", - ) - - return ET.tostring(root, xml_declaration=True, encoding="utf-8") - - class Joint(ABC): """Joint @@ -6660,6 +6896,12 @@ class Joint(ABC): Args: parent (Union[Solid, Compound]): object that joint to bound to + + Attributes: + label (str): user assigned label + parent (Shape): object joint is bound to + connected_to (Joint): joint that is connect to this joint + """ def __init__(self, label: str, parent: Union[Solid, Compound]): @@ -6667,513 +6909,31 @@ def __init__(self, label: str, parent: Union[Solid, Compound]): self.parent = parent self.connected_to: Joint = None - def connect_to(self, other: Joint, *args, **kwargs): # pragma: no cover + def _connect_to(self, other: Joint, **kwargs): # pragma: no cover """Connect Joint self by repositioning other""" if not isinstance(other, Joint): raise TypeError(f"other must of type Joint not {type(other)}") - relative_location = None - try: - relative_location = self.relative_to(other, *args, **kwargs) - except TypeError: - relative_location = other.relative_to(self, *args, **kwargs).inverse() - + relative_location = self.relative_to(other, **kwargs) other.parent.locate(self.parent.location * relative_location) - self.connected_to = other + @abstractmethod + def connect_to(self, other: Joint, **kwags): + """All derived classes must provide a connect_to method""" + raise NotImplementedError + @abstractmethod def relative_to(self, other: Joint, *args, **kwargs) -> Location: """Return relative location to another joint""" - return NotImplementedError + raise NotImplementedError @property @abstractmethod def symbol(self) -> Compound: # pragma: no cover """A CAD object positioned in global space to illustrate the joint""" - return NotImplementedError - - -class RigidJoint(Joint): - """RigidJoint - - A rigid joint fixes two components to one another. - - Args: - label (str): joint label - to_part (Union[Solid, Compound]): object to attach joint to - joint_location (Location): global location of joint - """ - - @property - def symbol(self) -> Compound: - """A CAD symbol (XYZ indicator) as bound to part""" - size = self.parent.bounding_box().diagonal / 12 - return SVG.axes(axes_scale=size).locate( - self.parent.location * self.relative_location - ) - - def __init__( - self, - label: str, - to_part: Union[Solid, Compound], - joint_location: Location = Location(), - ): - self.relative_location = to_part.location.inverse() * joint_location - to_part.joints[label] = self - super().__init__(label, to_part) - - def relative_to(self, other: Joint, **kwargs) -> Location: - """relative_to - - Return the relative position to move the other. - - Args: - other (RigidJoint): joint to connect to - """ - if not isinstance(other, RigidJoint): - raise TypeError(f"other must of type RigidJoint not {type(other)}") - - return self.relative_location * other.relative_location.inverse() - - -class RevoluteJoint(Joint): - """RevoluteJoint - - Component rotates around axis like a hinge. - - Args: - label (str): joint label - to_part (Union[Solid, Compound]): object to attach joint to - axis (Axis): axis of rotation - angle_reference (VectorLike, optional): direction normal to axis defining where - angles will be measured from. Defaults to None. - range (tuple[float, float], optional): (min,max) angle or joint. Defaults to (0, 360). - - Raises: - ValueError: angle_reference must be normal to axis - """ - - @property - def symbol(self) -> Compound: - """A CAD symbol representing the axis of rotation as bound to part""" - radius = self.parent.bounding_box().diagonal / 30 - - return Compound.make_compound( - [ - Edge.make_line((0, 0, 0), (0, 0, radius * 10)), - Edge.make_circle(radius), - ] - ).move(self.parent.location * self.relative_axis.to_location()) - - def __init__( - self, - label: str, - to_part: Union[Solid, Compound], - axis: Axis = Axis.Z, - angle_reference: VectorLike = None, - angular_range: tuple[float, float] = (0, 360), - ): - self.angular_range = angular_range - if angle_reference: - if not axis.is_normal(Axis((0, 0, 0), angle_reference)): - raise ValueError("angle_reference must be normal to axis") - self.angle_reference = Vector(angle_reference) - else: - self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir - self.angle = None - self.relative_axis = axis.located(to_part.location.inverse()) - to_part.joints[label] = self - super().__init__(label, to_part) - - def relative_to( - self, other: Joint, angle: float = None - ): # pylint: disable=arguments-differ - """relative_to - - Return the relative location from this joint to the RigidJoint of another object - - a hinge joint. - - Args: - other (RigidJoint): joint to connect to - angle (float, optional): angle within angular range. Defaults to minimum. - - Raises: - TypeError: other must of type RigidJoint - ValueError: angle out of range - """ - if not isinstance(other, RigidJoint): - raise TypeError(f"other must of type RigidJoint not {type(other)}") - - angle = self.angular_range[0] if angle is None else angle - if angle < self.angular_range[0] or angle > self.angular_range[1]: - raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") - self.angle = angle - # Avoid strange rotations when angle is zero by using 360 instead - angle = 360.0 if angle == 0.0 else angle - rotation = Location( - Plane( - origin=(0, 0, 0), - x_dir=self.angle_reference.rotate(Axis.Z, angle), - z_dir=(0, 0, 1), - ) - ) - return ( - self.relative_axis.to_location() - * rotation - * other.relative_location.inverse() - ) - - -class LinearJoint(Joint): - """LinearJoint - - Component moves along a single axis. - - Args: - label (str): joint label - to_part (Union[Solid, Compound]): object to attach joint to - axis (Axis): axis of linear motion - range (tuple[float, float], optional): (min,max) position of joint. - Defaults to (0, inf). - """ - - @property - def symbol(self) -> Compound: - """A CAD symbol of the linear axis positioned relative to_part""" - radius = (self.linear_range[1] - self.linear_range[0]) / 15 - return Compound.make_compound( - [ - Edge.make_line( - (0, 0, self.linear_range[0]), (0, 0, self.linear_range[1]) - ), - Edge.make_circle(radius), - ] - ).move(self.parent.location * self.relative_axis.to_location()) - - def __init__( - self, - label: str, - to_part: Union[Solid, Compound], - axis: Axis = Axis.Z, - linear_range: tuple[float, float] = (0, inf), - ): - self.axis = axis - self.linear_range = linear_range - self.position = None - self.relative_axis = axis.located(to_part.location.inverse()) - self.angle = None - to_part.joints[label]: dict[str, Joint] = self - super().__init__(label, to_part) - - @overload - def relative_to( - self, other: RigidJoint, position: float = None - ): # pylint: disable=arguments-differ - """relative_to - RigidJoint - - Return the relative location from this joint to the RigidJoint of another object - - a slider joint. - - Args: - other (RigidJoint): joint to connect to - position (float, optional): position within joint range. Defaults to middle. - """ - - @overload - def relative_to( - self, other: RevoluteJoint, position: float = None, angle: float = None - ): # pylint: disable=arguments-differ - """relative_to - RevoluteJoint - - Return the relative location from this joint to the RevoluteJoint of another object - - a pin slot joint. - - Args: - other (RigidJoint): joint to connect to - position (float, optional): position within joint range. Defaults to middle. - angle (float, optional): angle within angular range. Defaults to minimum. - """ - - def relative_to(self, *args, **kwargs): # pylint: disable=arguments-differ - """Return the relative position of other to linear joint defined by self""" - - # Parse the input parameters - other, position, angle = None, None, None - if args: - other = args[0] - position = args[1] if len(args) >= 2 else position - angle = args[2] if len(args) == 3 else angle - - if kwargs: - other = kwargs["other"] if "other" in kwargs else other - position = kwargs["position"] if "position" in kwargs else position - angle = kwargs["angle"] if "angle" in kwargs else angle - - if not isinstance(other, (RigidJoint, RevoluteJoint)): - raise TypeError( - f"other must of type RigidJoint or RevoluteJoint not {type(other)}" - ) - - position = sum(self.linear_range) / 2 if position is None else position - if not self.linear_range[0] <= position <= self.linear_range[1]: - raise ValueError( - f"position ({position}) must in range of {self.linear_range}" - ) - self.position = position - - if isinstance(other, RevoluteJoint): - angle = other.angular_range[0] if angle is None else angle - if not other.angular_range[0] <= angle <= other.angular_range[1]: - raise ValueError( - f"angle ({angle}) must in range of {other.angular_range}" - ) - rotation = Location( - Plane( - origin=(0, 0, 0), - x_dir=other.angle_reference.rotate(other.relative_axis, angle), - z_dir=other.relative_axis.direction, - ) - ) - else: - angle = 0.0 - rotation = Location() - self.angle = angle - joint_relative_position = ( - Location( - self.relative_axis.position + self.relative_axis.direction * position, - ) - * rotation - ) - - if isinstance(other, RevoluteJoint): - other_relative_location = Location(other.relative_axis.position) - else: - other_relative_location = other.relative_location - - return joint_relative_position * other_relative_location.inverse() - - -class CylindricalJoint(Joint): - """CylindricalJoint - - Component rotates around and moves along a single axis like a screw. - - Args: - label (str): joint label - to_part (Union[Solid, Compound]): object to attach joint to - axis (Axis): axis of rotation and linear motion - angle_reference (VectorLike, optional): direction normal to axis defining where - angles will be measured from. Defaults to None. - linear_range (tuple[float, float], optional): (min,max) position of joint. - Defaults to (0, inf). - angular_range (tuple[float, float], optional): (min,max) angle of joint. - Defaults to (0, 360). - - Raises: - ValueError: angle_reference must be normal to axis - """ - - @property - def symbol(self) -> Compound: - """A CAD symbol representing the cylindrical axis as bound to part""" - radius = (self.linear_range[1] - self.linear_range[0]) / 15 - return Compound.make_compound( - [ - Edge.make_line( - (0, 0, self.linear_range[0]), (0, 0, self.linear_range[1]) - ), - Edge.make_circle(radius), - ] - ).move(self.parent.location * self.relative_axis.to_location()) - - # @property - # def axis_location(self) -> Location: - # """Current global location of joint axis""" - # return self.parent.location * self.relative_axis.to_location() - - def __init__( - self, - label: str, - to_part: Union[Solid, Compound], - axis: Axis = Axis.Z, - angle_reference: VectorLike = None, - linear_range: tuple[float, float] = (0, inf), - angular_range: tuple[float, float] = (0, 360), - ): - self.axis = axis - self.linear_position = None - self.rotational_position = None - if angle_reference: - if not axis.is_normal(Axis((0, 0, 0), angle_reference)): - raise ValueError("angle_reference must be normal to axis") - self.angle_reference = Vector(angle_reference) - else: - self.angle_reference = Plane(origin=(0, 0, 0), z_dir=axis.direction).x_dir - self.angular_range = angular_range - self.linear_range = linear_range - self.relative_axis = axis.located(to_part.location.inverse()) - self.position = None - self.angle = None - to_part.joints[label]: dict[str, Joint] = self - super().__init__(label, to_part) - - def relative_to( - self, other: RigidJoint, position: float = None, angle: float = None - ): # pylint: disable=arguments-differ - """relative_to - CylindricalJoint - - Return the relative location from this joint to the RigidJoint of another object - - a sliding and rotating joint. - - Args: - other (RigidJoint): joint to connect to - position (float, optional): position within joint linear range. Defaults to middle. - angle (float, optional): angle within rotational range. - Defaults to angular_range minimum. - - Raises: - TypeError: other must be of type RigidJoint - ValueError: position out of range - ValueError: angle out of range - """ - if not isinstance(other, RigidJoint): - raise TypeError(f"other must of type RigidJoint not {type(other)}") - - position = sum(self.linear_range) / 2 if position is None else position - if not self.linear_range[0] <= position <= self.linear_range[1]: - raise ValueError( - f"position ({position}) must in range of {self.linear_range}" - ) - self.position = position - angle = sum(self.angular_range) / 2 if angle is None else angle - if not self.angular_range[0] <= angle <= self.angular_range[1]: - raise ValueError(f"angle ({angle}) must in range of {self.angular_range}") - self.angle = angle - - joint_relative_position = Location( - self.relative_axis.position + self.relative_axis.direction * position - ) - joint_rotation = Location( - Plane( - origin=(0, 0, 0), - x_dir=self.angle_reference.rotate(self.relative_axis, angle), - z_dir=self.relative_axis.direction, - ) - ) - - return ( - joint_relative_position * joint_rotation * other.relative_location.inverse() - ) - - -class BallJoint(Joint): - """BallJoint - - A component rotates around all 3 axes using a gimbal system (3 nested rotations). - - Args: - label (str): joint label - to_part (Union[Solid, Compound]): object to attach joint to - joint_location (Location): global location of joint - angular_range - (tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional): - X, Y, Z angle (min, max) pairs. Defaults to ((0, 360), (0, 360), (0, 360)). - angle_reference (Plane, optional): plane relative to part defining zero degrees of - rotation. Defaults to Plane.XY. - """ - - @property - def symbol(self) -> Compound: - """A CAD symbol representing joint as bound to part""" - radius = self.parent.bounding_box().diagonal / 30 - circle_x = Edge.make_circle(radius, self.angle_reference) - circle_y = Edge.make_circle(radius, self.angle_reference.rotated((90, 0, 0))) - circle_z = Edge.make_circle(radius, self.angle_reference.rotated((0, 90, 0))) - - return Compound.make_compound( - [ - circle_x, - circle_y, - circle_z, - Compound.make_text( - "X", radius / 5, align=(Align.CENTER, Align.CENTER) - ).locate(circle_x.location_at(0.125) * Rotation(90, 0, 0)), - Compound.make_text( - "Y", radius / 5, align=(Align.CENTER, Align.CENTER) - ).locate(circle_y.location_at(0.625) * Rotation(90, 0, 0)), - Compound.make_text( - "Z", radius / 5, align=(Align.CENTER, Align.CENTER) - ).locate(circle_z.location_at(0.125) * Rotation(90, 0, 0)), - ] - ).move(self.parent.location * self.relative_location) - - def __init__( - self, - label: str, - to_part: Union[Solid, Compound], - joint_location: Location = Location(), - angular_range: tuple[ - tuple[float, float], tuple[float, float], tuple[float, float] - ] = ((0, 360), (0, 360), (0, 360)), - angle_reference: Plane = Plane.XY, - ): - """_summary_ - - _extended_summary_ - - Args: - label (str): _description_ - to_part (Union[Solid, Compound]): _description_ - joint_location (Location, optional): _description_. Defaults to Location(). - angular_range - (tuple[ tuple[float, float], tuple[float, float], tuple[float, float] ], optional): - _description_. Defaults to ((0, 360), (0, 360), (0, 360)). - angle_reference (Plane, optional): _description_. Defaults to Plane.XY. - """ - self.relative_location = to_part.location.inverse() * joint_location - to_part.joints[label] = self - self.angular_range = angular_range - self.angle_reference = angle_reference - super().__init__(label, to_part) - - def relative_to( - self, other: RigidJoint, angles: RotationLike = None - ): # pylint: disable=arguments-differ - """relative_to - CylindricalJoint - - Return the relative location from this joint to the RigidJoint of another object - - Args: - other (RigidJoint): joint to connect to - angles (RotationLike, optional): orientation of other's parent relative to - self. Defaults to the minimums of the angle ranges. - - Raises: - TypeError: invalid other joint type - ValueError: angles out of range - """ - - if not isinstance(other, RigidJoint): - raise TypeError(f"other must of type RigidJoint not {type(other)}") - - rotation = ( - Rotation(*[self.angular_range[i][0] for i in [0, 1, 2]]) - if angles is None - else Rotation(*angles) - ) * self.angle_reference.to_location() - - for i, rotations in zip( - [0, 1, 2], - [rotation.orientation.X, rotation.orientation.Y, rotation.orientation.Z], - ): - if not self.angular_range[i][0] <= rotations <= self.angular_range[i][1]: - raise ValueError( - f"angles ({angles}) must in range of {self.angular_range}" - ) - - return self.relative_location * rotation * other.relative_location.inverse() + raise NotImplementedError def downcast(obj: TopoDS_Shape) -> TopoDS_Shape: @@ -7311,6 +7071,41 @@ def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shap return shape_delta +def new_edges(*objects: Shape, combined: Shape) -> ShapeList[Edge]: + """new_edges + + Given a sequence of shapes and the combination of those shapes, find the newly added edges + + Args: + objects (Shape): sequence of shapes + combined (Shape): result of the combination of objects + + Returns: + ShapeList[Edge]: new edges + """ + # Create a list of combined object edges + combined_topo_edges = TopTools_ListOfShape() + for edge in combined.edges(): + combined_topo_edges.Append(edge.wrapped) + + # Create a list of original object edges + original_topo_edges = TopTools_ListOfShape() + for edge in [e for obj in objects for e in obj.edges()]: + original_topo_edges.Append(edge.wrapped) + + # Cut the original edges from the combined edges + operation = BRepAlgoAPI_Cut() + operation.SetArguments(combined_topo_edges) + operation.SetTools(original_topo_edges) + operation.SetRunParallel(True) + operation.Build() + + new_edges = Shape.cast(operation.Shape()).edges() + for edge in new_edges: + edge.topo_parent = combined + return ShapeList(new_edges) + + class SkipClean: """Skip clean context for use in operator driven code where clean=False wouldn't work""" diff --git a/tests/test_algebra.py b/tests/test_algebra.py index 3a71637a..2a2fd027 100644 --- a/tests/test_algebra.py +++ b/tests/test_algebra.py @@ -690,6 +690,27 @@ def test_chamfer_2d(self): self.assertAlmostEqual(r.area, 2.0, 6) self.assertAlmostEqual(c.area, 1.92, 4) + def test_extrude(self): + s = Circle(1) + p = extrude(s, amount=1) + p = chamfer(p.edges().filter_by(GeomType.CIRCLE), 0.3) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 4) + + def test_extrude_both(self): + s = Circle(1) + p = extrude(s, amount=1, both=True) + self.assertAlmostEqual(p.bounding_box().size.Z, 2, 4) + + +class RightMultipleTests(unittest.TestCase): + def test_rmul(self): + c = [Location((1, 2, 3))] * Circle(1) + self.assertTupleAlmostEquals(c[0].position, (1, 2, 3), 6) + + def test_rmul_error(self): + with self.assertRaises(ValueError): + [Vector(1, 2, 3)] * Circle(1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_build_common.py b/tests/test_build_common.py index 8aec70c9..ef1cb365 100644 --- a/tests/test_build_common.py +++ b/tests/test_build_common.py @@ -26,6 +26,7 @@ """ import unittest +from math import pi from build123d import * from build123d import Builder, WorkplaneList, LocationList @@ -39,6 +40,99 @@ def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): unittest.TestCase.assertTupleAlmostEquals = _assertTupleAlmostEquals +class TestBuilder(unittest.TestCase): + """Test the Builder base class""" + + def test_exit(self): + """test transferring objects to parent""" + with BuildPart() as outer: + with BuildSketch() as inner: + Circle(1) + self.assertEqual(len(outer.pending_faces), 1) + with BuildSketch() as inner: + with BuildLine(): + CenterArc((0, 0), 1, 0, 360) + make_face() + self.assertEqual(len(outer.pending_faces), 2) + + def test_plane_with_no_x(self): + with BuildPart() as p: + Box(1, 1, 1) + front = p.faces().sort_by(Axis.X)[-1] + with BuildSketch(front): + offset(front, amount=-0.1) + extrude(amount=0.1) + self.assertAlmostEqual(p.part.volume, 1**3 + 0.1 * (1 - 2 * 0.1) ** 2, 4) + + def test_no_workplane(self): + with BuildSketch() as s: + Circle(1) + + def test_vertex(self): + with BuildLine() as l: + CenterArc((0, 0), 1, 0, 360) + v = l.vertex() + self.assertTrue(isinstance(v, Vertex)) + + with BuildLine() as l: + CenterArc((0, 0), 1, 0, 90) + with self.assertWarns(UserWarning): + l.vertex() + + def test_edge(self): + with BuildLine() as l: + CenterArc((0, 0), 1, 0, 90) + e = l.edge() + self.assertTrue(isinstance(e, Edge)) + + with BuildSketch() as s: + Rectangle(1, 1) + with self.assertWarns(UserWarning): + s.edge() + + def test_wire(self): + with BuildSketch() as s: + Rectangle(1, 1) + w = s.wire() + self.assertTrue(isinstance(w, Wire)) + + with BuildPart() as p: + Box(1, 1, 1) + with self.assertWarns(UserWarning): + p.wire() + + def test_face(self): + with BuildSketch() as s: + Rectangle(1, 1) + f = s.face() + self.assertTrue(isinstance(f, Face)) + + with BuildPart() as p: + Box(1, 1, 1) + with self.assertWarns(UserWarning): + p.face() + + def test_solid(self): + s = Box(1, 1, 1).solid() + self.assertTrue(isinstance(s, Solid)) + + with BuildPart() as p: + with BuildSketch(): + Text("Two", 10) + extrude(amount=5) + with self.assertWarns(UserWarning): + p.solid() + + +class TestBuilderExit(unittest.TestCase): + def test_multiple(self): + with BuildPart() as test: + with BuildLine() as l: + Line((0, 0), (1, 0)) + Line((0, 0), (0, 1)) + self.assertEqual(len(test.pending_edges), 2) + + class TestCommonOperations(unittest.TestCase): """Test custom operators""" @@ -53,6 +147,141 @@ def test_mod(self): ) +class TestLocations(unittest.TestCase): + def test_polar_locations(self): + locs = PolarLocations(1, 5, 45, 90, False).local_locations + for i, angle in enumerate(range(45, 135, 18)): + self.assertTupleAlmostEquals( + locs[i].position.to_tuple(), + Vector(1, 0).rotate(Axis.Z, angle).to_tuple(), + 5, + ) + self.assertTupleAlmostEquals(locs[i].orientation.to_tuple(), (0, 0, 0), 5) + + def test_no_centering(self): + with BuildSketch(): + with GridLocations(4, 4, 2, 2, align=(Align.MIN, Align.MIN)) as l: + pts = [loc.to_tuple()[0] for loc in l.locations] + self.assertTupleAlmostEquals(pts[0], (0, 0, 0), 5) + self.assertTupleAlmostEquals(pts[1], (0, 4, 0), 5) + self.assertTupleAlmostEquals(pts[2], (4, 0, 0), 5) + self.assertTupleAlmostEquals(pts[3], (4, 4, 0), 5) + positions = [ + l.position + for l in GridLocations( + 1, 1, 2, 2, align=(Align.MIN, Align.MIN) + ).local_locations + ] + for position in positions: + self.assertTrue(position.X >= 0 and position.Y >= 0) + positions = [ + l.position + for l in GridLocations( + 1, 1, 2, 2, align=(Align.MAX, Align.MAX) + ).local_locations + ] + for position in positions: + self.assertTrue(position.X <= 0 and position.Y <= 0) + + def test_hex_no_centering(self): + positions = [ + l.position + for l in HexLocations(1, 2, 2, align=(Align.MIN, Align.MIN)).local_locations + ] + for position in positions: + self.assertTrue(position.X >= 0 and position.Y >= 0) + positions = [ + l.position + for l in HexLocations(1, 2, 2, align=(Align.MAX, Align.MAX)).local_locations + ] + for position in positions: + self.assertTrue(position.X <= 0 and position.Y <= 0) + + def test_centering(self): + with BuildSketch(): + with GridLocations(4, 4, 2, 2, align=(Align.CENTER, Align.CENTER)) as l: + pts = [loc.to_tuple()[0] for loc in l.locations] + self.assertTupleAlmostEquals(pts[0], (-2, -2, 0), 5) + self.assertTupleAlmostEquals(pts[1], (-2, 2, 0), 5) + self.assertTupleAlmostEquals(pts[2], (2, -2, 0), 5) + self.assertTupleAlmostEquals(pts[3], (2, 2, 0), 5) + + def test_nesting(self): + with BuildSketch(): + with Locations((-2, -2), (2, 2)): + with GridLocations(1, 1, 2, 2) as nested_grid: + pts = [loc.to_tuple()[0] for loc in nested_grid.local_locations] + self.assertTupleAlmostEquals(pts[0], (-2.50, -2.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[1], (-2.50, -1.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[2], (-1.50, -2.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[3], (-1.50, -1.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[4], (1.50, 1.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[5], (1.50, 2.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[6], (2.50, 1.50, 0.00), 5) + self.assertTupleAlmostEquals(pts[7], (2.50, 2.50, 0.00), 5) + + def test_polar_nesting(self): + with BuildSketch(): + with PolarLocations(6, 3): + with GridLocations(1, 1, 2, 2) as polar_grid: + pts = [loc.to_tuple()[0] for loc in polar_grid.local_locations] + ort = [loc.to_tuple()[1] for loc in polar_grid.local_locations] + + self.assertTupleAlmostEquals(pts[0], (5.50, -0.50, 0.00), 2) + self.assertTupleAlmostEquals(pts[1], (5.50, 0.50, 0.00), 2) + self.assertTupleAlmostEquals(pts[2], (6.50, -0.50, 0.00), 2) + self.assertTupleAlmostEquals(pts[3], (6.50, 0.50, 0.00), 2) + self.assertTupleAlmostEquals(pts[4], (-2.32, 5.01, 0.00), 2) + self.assertTupleAlmostEquals(pts[5], (-3.18, 4.51, 0.00), 2) + self.assertTupleAlmostEquals(pts[6], (-2.82, 5.88, 0.00), 2) + self.assertTupleAlmostEquals(pts[7], (-3.68, 5.38, 0.00), 2) + self.assertTupleAlmostEquals(pts[8], (-3.18, -4.51, 0.00), 2) + self.assertTupleAlmostEquals(pts[9], (-2.32, -5.01, 0.00), 2) + self.assertTupleAlmostEquals(pts[10], (-3.68, -5.38, 0.00), 2) + self.assertTupleAlmostEquals(pts[11], (-2.82, -5.88, 0.00), 2) + + self.assertTupleAlmostEquals(ort[0], (-0.00, 0.00, -0.00), 2) + self.assertTupleAlmostEquals(ort[1], (-0.00, 0.00, -0.00), 2) + self.assertTupleAlmostEquals(ort[2], (-0.00, 0.00, -0.00), 2) + self.assertTupleAlmostEquals(ort[3], (-0.00, 0.00, -0.00), 2) + self.assertTupleAlmostEquals(ort[4], (-0.00, 0.00, 120.00), 2) + self.assertTupleAlmostEquals(ort[5], (-0.00, 0.00, 120.00), 2) + self.assertTupleAlmostEquals(ort[6], (-0.00, 0.00, 120.00), 2) + self.assertTupleAlmostEquals(ort[7], (-0.00, 0.00, 120.00), 2) + self.assertTupleAlmostEquals(ort[8], (-0.00, 0.00, -120.00), 2) + self.assertTupleAlmostEquals(ort[9], (-0.00, 0.00, -120.00), 2) + self.assertTupleAlmostEquals(ort[10], (-0.00, 0.00, -120.00), 2) + self.assertTupleAlmostEquals(ort[11], (-0.00, 0.00, -120.00), 2) + + def test_from_face(self): + square = Face.make_rect(1, 1, Plane.XZ) + with BuildPart(): + loc = Locations(square).locations[0] + self.assertTupleAlmostEquals( + loc.position.to_tuple(), Location(Plane.XZ).position.to_tuple(), 5 + ) + self.assertTupleAlmostEquals( + loc.orientation.to_tuple(), Location(Plane.XZ).orientation.to_tuple(), 5 + ) + + def test_from_plane(self): + with BuildPart(): + loc = Locations(Plane.XY.offset(1)).locations[0] + self.assertTupleAlmostEquals(loc.position.to_tuple(), (0, 0, 1), 5) + + def test_from_axis(self): + with BuildPart(): + loc = Locations(Axis((1, 1, 1), (0, 0, 1))).locations[0] + self.assertTupleAlmostEquals(loc.position.to_tuple(), (1, 1, 1), 5) + + def test_multiplication(self): + circles = GridLocations(2, 2, 2, 2) * Circle(1) + self.assertEqual(len(circles), 4) + + with self.assertRaises(ValueError): + GridLocations(2, 2, 2, 2) * "error" + + class TestProperties(unittest.TestCase): def test_vector_properties(self): v = Vector(1, 2, 3) @@ -233,18 +462,30 @@ def test_vertices(self): Box(1, 1, 1) self.assertEqual(len(test.part.vertices()), 8) self.assertTrue(isinstance(test.part.vertices(), ShapeList)) + with self.assertRaises(ValueError): + with BuildPart() as test: + Box(1, 1, 1) + v = test.vertices("ALL") def test_edges(self): with BuildPart() as test: Box(1, 1, 1) self.assertEqual(len(test.part.edges()), 12) self.assertTrue(isinstance(test.part.edges(), ShapeList)) + with self.assertRaises(ValueError): + with BuildPart() as test: + Box(1, 1, 1) + v = test.edges("ALL") def test_wires(self): with BuildPart() as test: Box(1, 1, 1) self.assertEqual(len(test.wires()), 6) self.assertTrue(isinstance(test.wires(), ShapeList)) + with self.assertRaises(ValueError): + with BuildPart() as test: + Box(1, 1, 1) + v = test.wires("ALL") def test_wires_last(self): with BuildPart() as test: @@ -258,12 +499,20 @@ def test_faces(self): Box(1, 1, 1) self.assertEqual(len(test.part.faces()), 6) self.assertTrue(isinstance(test.part.faces(), ShapeList)) + with self.assertRaises(ValueError): + with BuildPart() as test: + Box(1, 1, 1) + v = test.faces("ALL") def test_solids(self): with BuildPart() as test: Box(1, 1, 1) self.assertEqual(len(test.part.solids()), 1) self.assertTrue(isinstance(test.part.solids(), ShapeList)) + with self.assertRaises(ValueError): + with BuildPart() as test: + Box(1, 1, 1) + v = test.solids("ALL") def test_compounds(self): with BuildPart() as test: @@ -271,255 +520,36 @@ def test_compounds(self): self.assertEqual(len(test.part.compounds()), 1) self.assertTrue(isinstance(test.part.compounds(), ShapeList)) - -class TestBuilder(unittest.TestCase): - """Test the Builder base class""" - - def test_exit(self): - """test transferring objects to parent""" - with BuildPart() as outer: - with BuildSketch() as inner: - Circle(1) - self.assertEqual(len(outer.pending_faces), 1) - with BuildSketch() as inner: - with BuildLine(): - CenterArc((0, 0), 1, 0, 360) - make_face() - self.assertEqual(len(outer.pending_faces), 2) - - -class TestWorkplanes(unittest.TestCase): - def test_named(self): - with Workplanes(Plane.XY) as test: - self.assertTupleAlmostEquals( - test.workplanes[0].origin.to_tuple(), (0, 0, 0), 5 - ) - self.assertTupleAlmostEquals( - test.workplanes[0].z_dir.to_tuple(), (0, 0, 1), 5 - ) - - def test_locations(self): - with Workplanes(Plane.XY): - with Locations((0, 0, 1), (0, 0, 2)) as l: - with Workplanes(*l.locations) as w: - origins = [p.origin.to_tuple() for p in w.workplanes] - self.assertTupleAlmostEquals(origins[0], (0, 0, 1), 5) - self.assertTupleAlmostEquals(origins[1], (0, 0, 2), 5) - self.assertEqual(len(origins), 2) - - def test_grid_locations(self): - with Workplanes(Plane(origin=(1, 2, 3))): - locs = GridLocations(4, 6, 2, 2).locations - self.assertTupleAlmostEquals(locs[0].position.to_tuple(), (-1, -1, 3), 5) - self.assertTupleAlmostEquals(locs[1].position.to_tuple(), (-1, 5, 3), 5) - self.assertTupleAlmostEquals(locs[2].position.to_tuple(), (3, -1, 3), 5) - self.assertTupleAlmostEquals(locs[3].position.to_tuple(), (3, 5, 3), 5) - - def test_conversions(self): - loc = Location((1, 2, 3), (23, 45, 67)) - loc2 = Workplanes(loc).workplanes[0].to_location() - self.assertTupleAlmostEquals(loc.to_tuple()[0], loc2.to_tuple()[0], 6) - self.assertTupleAlmostEquals(loc.to_tuple()[1], loc2.to_tuple()[1], 6) - - loc = Location((-10, -2, 30), (-123, 145, 267)) - face = Face.make_rect(1, 1).move(loc) - loc2 = Workplanes(face).workplanes[0].to_location() - face2 = Face.make_rect(1, 1).move(loc2) - self.assertTupleAlmostEquals( - face.center().to_tuple(), face2.center().to_tuple(), 6 - ) - self.assertTupleAlmostEquals( - face.normal_at(face.center()).to_tuple(), - face2.normal_at(face2.center()).to_tuple(), - 6, - ) - - def test_bad_plane(self): - with self.assertRaises(ValueError): - with BuildPart(4): - pass - - def test_locations_after_new_workplane(self): - with BuildPart(Plane.XY): - with Locations((0, 1, 2), (3, 4, 5)): - with BuildPart(Plane.XY.offset(2)): - self.assertTupleAlmostEquals( - LocationList._get_context().locations[0].position.to_tuple(), - (0, 0, 2), - 5, - ) - Box(1, 1, 1) - - -class TestWorkplaneList(unittest.TestCase): - def test_iter(self): - for i, plane in enumerate(WorkplaneList([Plane.XY, Plane.YZ])): - if i == 0: - self.assertTrue(plane == Plane.XY) - elif i == 1: - self.assertTrue(plane == Plane.YZ) - - def test_localize(self): - with BuildLine(Plane.YZ): - pnts = WorkplaneList.localize((1, 2), (2, 3)) - self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 5) - self.assertTupleAlmostEquals(pnts[1].to_tuple(), (0, 2, 3), 5) + def test_shapes(self): + with BuildPart() as test: + Box(1, 1, 1) + self.assertIsNone(test._shapes(Compound)) class TestValidateInputs(unittest.TestCase): - # def test_no_builder(self): - # with self.assertRaises(RuntimeError): - # Circle(1) - def test_wrong_builder(self): - with self.assertRaises(RuntimeError): + with self.assertRaises(RuntimeError) as rte: with BuildPart(): Circle(1) - - def test_bad_builder_input(self): - with self.assertRaises(RuntimeError): - with BuildPart() as p: - Box(1, 1, 1) - with BuildSketch(): - add(p) + self.assertEqual( + "BuildPart doesn't have a Circle object or operation (Circle applies to ['BuildSketch'])", + str(rte.exception), + ) def test_no_sequence(self): - with self.assertRaises(ValueError): + with self.assertRaises(ValueError) as rte: with BuildPart() as p: Box(1, 1, 1) fillet([None, None], radius=1) + self.assertEqual("3D fillet operation takes only Edges", str(rte.exception)) def test_wrong_type(self): - with self.assertRaises(RuntimeError): + with self.assertRaises(RuntimeError) as rte: with BuildPart() as p: Box(1, 1, 1) fillet(4, radius=1) - - -class TestBuilderExit(unittest.TestCase): - def test_multiple(self): - with BuildPart() as test: - with BuildLine() as l: - Line((0, 0), (1, 0)) - Line((0, 0), (0, 1)) - self.assertEqual(len(test.pending_edges), 2) - - -class TestLocations(unittest.TestCase): - def test_polar_locations(self): - locs = PolarLocations(1, 5, 45, 90, False).local_locations - for i, angle in enumerate(range(45, 135, 18)): - self.assertTupleAlmostEquals( - locs[i].position.to_tuple(), - Vector(1, 0).rotate(Axis.Z, angle).to_tuple(), - 5, - ) - self.assertTupleAlmostEquals(locs[i].orientation.to_tuple(), (0, 0, 0), 5) - - def test_no_centering(self): - with BuildSketch(): - with GridLocations(4, 4, 2, 2, align=(Align.MIN, Align.MIN)) as l: - pts = [loc.to_tuple()[0] for loc in l.locations] - self.assertTupleAlmostEquals(pts[0], (0, 0, 0), 5) - self.assertTupleAlmostEquals(pts[1], (0, 4, 0), 5) - self.assertTupleAlmostEquals(pts[2], (4, 0, 0), 5) - self.assertTupleAlmostEquals(pts[3], (4, 4, 0), 5) - positions = [ - l.position - for l in GridLocations( - 1, 1, 2, 2, align=(Align.MIN, Align.MIN) - ).local_locations - ] - for position in positions: - self.assertTrue(position.X >= 0 and position.Y >= 0) - positions = [ - l.position - for l in GridLocations( - 1, 1, 2, 2, align=(Align.MAX, Align.MAX) - ).local_locations - ] - for position in positions: - self.assertTrue(position.X <= 0 and position.Y <= 0) - - def test_hex_no_centering(self): - positions = [ - l.position - for l in HexLocations(1, 2, 2, align=(Align.MIN, Align.MIN)).local_locations - ] - for position in positions: - self.assertTrue(position.X >= 0 and position.Y >= 0) - positions = [ - l.position - for l in HexLocations(1, 2, 2, align=(Align.MAX, Align.MAX)).local_locations - ] - for position in positions: - self.assertTrue(position.X <= 0 and position.Y <= 0) - - def test_centering(self): - with BuildSketch(): - with GridLocations(4, 4, 2, 2, align=(Align.CENTER, Align.CENTER)) as l: - pts = [loc.to_tuple()[0] for loc in l.locations] - self.assertTupleAlmostEquals(pts[0], (-2, -2, 0), 5) - self.assertTupleAlmostEquals(pts[1], (-2, 2, 0), 5) - self.assertTupleAlmostEquals(pts[2], (2, -2, 0), 5) - self.assertTupleAlmostEquals(pts[3], (2, 2, 0), 5) - - def test_nesting(self): - with BuildSketch(): - with Locations((-2, -2), (2, 2)): - with GridLocations(1, 1, 2, 2) as nested_grid: - pts = [loc.to_tuple()[0] for loc in nested_grid.local_locations] - self.assertTupleAlmostEquals(pts[0], (-2.50, -2.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[1], (-2.50, -1.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[2], (-1.50, -2.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[3], (-1.50, -1.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[4], (1.50, 1.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[5], (1.50, 2.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[6], (2.50, 1.50, 0.00), 5) - self.assertTupleAlmostEquals(pts[7], (2.50, 2.50, 0.00), 5) - - def test_polar_nesting(self): - with BuildSketch(): - with PolarLocations(6, 3): - with GridLocations(1, 1, 2, 2) as polar_grid: - pts = [loc.to_tuple()[0] for loc in polar_grid.local_locations] - ort = [loc.to_tuple()[1] for loc in polar_grid.local_locations] - - self.assertTupleAlmostEquals(pts[0], (5.50, -0.50, 0.00), 2) - self.assertTupleAlmostEquals(pts[1], (5.50, 0.50, 0.00), 2) - self.assertTupleAlmostEquals(pts[2], (6.50, -0.50, 0.00), 2) - self.assertTupleAlmostEquals(pts[3], (6.50, 0.50, 0.00), 2) - self.assertTupleAlmostEquals(pts[4], (-2.32, 5.01, 0.00), 2) - self.assertTupleAlmostEquals(pts[5], (-3.18, 4.51, 0.00), 2) - self.assertTupleAlmostEquals(pts[6], (-2.82, 5.88, 0.00), 2) - self.assertTupleAlmostEquals(pts[7], (-3.68, 5.38, 0.00), 2) - self.assertTupleAlmostEquals(pts[8], (-3.18, -4.51, 0.00), 2) - self.assertTupleAlmostEquals(pts[9], (-2.32, -5.01, 0.00), 2) - self.assertTupleAlmostEquals(pts[10], (-3.68, -5.38, 0.00), 2) - self.assertTupleAlmostEquals(pts[11], (-2.82, -5.88, 0.00), 2) - - self.assertTupleAlmostEquals(ort[0], (-0.00, 0.00, -0.00), 2) - self.assertTupleAlmostEquals(ort[1], (-0.00, 0.00, -0.00), 2) - self.assertTupleAlmostEquals(ort[2], (-0.00, 0.00, -0.00), 2) - self.assertTupleAlmostEquals(ort[3], (-0.00, 0.00, -0.00), 2) - self.assertTupleAlmostEquals(ort[4], (-0.00, 0.00, 120.00), 2) - self.assertTupleAlmostEquals(ort[5], (-0.00, 0.00, 120.00), 2) - self.assertTupleAlmostEquals(ort[6], (-0.00, 0.00, 120.00), 2) - self.assertTupleAlmostEquals(ort[7], (-0.00, 0.00, 120.00), 2) - self.assertTupleAlmostEquals(ort[8], (-0.00, 0.00, -120.00), 2) - self.assertTupleAlmostEquals(ort[9], (-0.00, 0.00, -120.00), 2) - self.assertTupleAlmostEquals(ort[10], (-0.00, 0.00, -120.00), 2) - self.assertTupleAlmostEquals(ort[11], (-0.00, 0.00, -120.00), 2) - - def test_from_face(self): - square = Face.make_rect(1, 1, Plane.XZ) - with BuildPart(): - loc = Locations(square).locations[0] - self.assertTupleAlmostEquals( - loc.position.to_tuple(), Location(Plane.XZ).position.to_tuple(), 5 - ) - self.assertTupleAlmostEquals( - loc.orientation.to_tuple(), Location(Plane.XZ).orientation.to_tuple(), 5 + self.assertEqual( + "fillet doesn't accept int, did you intend =4?", str(rte.exception) ) @@ -555,6 +585,51 @@ def test_vector_localization(self): 5, ) + def test_relative_addition_with_non_zero_origin(self): + pln = Plane.XZ + pln.origin = (0, 0, -35) + + with BuildLine(pln): + n3 = Line((-50, -40), (0, 0)) + n4 = Line(n3 @ 1, n3 @ 1 + (0, 10)) + self.assertTupleAlmostEquals((n4 @ 1).to_tuple(), (0, 0, -25), 5) + + +class TestWorkplaneList(unittest.TestCase): + def test_iter(self): + for i, plane in enumerate(WorkplaneList(Plane.XY, Plane.YZ)): + if i == 0: + self.assertTrue(plane == Plane.XY) + elif i == 1: + self.assertTrue(plane == Plane.YZ) + + def test_localize(self): + with BuildLine(Plane.YZ): + pnts = WorkplaneList.localize((1, 2), (2, 3)) + self.assertTupleAlmostEquals(pnts[0].to_tuple(), (0, 1, 2), 5) + self.assertTupleAlmostEquals(pnts[1].to_tuple(), (0, 2, 3), 5) + + def test_invalid_workplane(self): + with self.assertRaises(ValueError): + WorkplaneList(Vector(1, 1, 1)) + + +class TestWorkplaneStorage(unittest.TestCase): + def test_store_workplanes(self): + with BuildPart(Face.make_rect(5, 5, Plane.XZ)) as p1: + Box(1, 1, 1) + with BuildSketch(*p1.faces()) as s1: + with BuildLine(Location()) as l1: + CenterArc((0, 0), 0.2, 0, 360) + self.assertEqual(len(l1.workplanes), 1) + self.assertTrue(l1.workplanes[0] == Plane.XY) + make_face() + # Circle(0.2) + self.assertEqual(len(s1.workplanes), 6) + self.assertTrue(all([isinstance(p, Plane) for p in s1.workplanes])) + extrude(amount=0.1) + self.assertTrue(p1.workplanes[0] == Plane.XZ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_build_generic.py b/tests/test_build_generic.py index 23cbf723..f29410cf 100644 --- a/tests/test_build_generic.py +++ b/tests/test_build_generic.py @@ -26,7 +26,7 @@ """ import unittest -from math import pi +from math import pi, sqrt from build123d import * from build123d import Builder, LocationList @@ -89,6 +89,21 @@ def test_add_to_part(self): with BuildPart() as test: add(Solid.make_box(10, 10, 10)) self.assertAlmostEqual(test.part.volume, 1000, 5) + with BuildPart() as test: + add(Solid.make_box(10, 10, 10), rotation=(0, 0, 45)) + self.assertAlmostEqual(test.part.volume, 1000, 5) + self.assertTupleAlmostEquals( + ( + test.part.edges() + .group_by(Axis.Z)[-1] + .group_by(Axis.X)[-1] + .sort_by(Axis.Y)[0] + % 1 + ).to_tuple(), + (sqrt(2) / 2, sqrt(2) / 2, 0), + 5, + ) + # Add Compound with BuildPart() as test: add( @@ -100,6 +115,10 @@ def test_add_to_part(self): ) ) self.assertEqual(test.part.volume, 1125, 5) + with BuildPart() as test: + add(Compound.make_compound([Edge.make_line((0, 0), (1, 1))])) + self.assertEqual(len(test.pending_edges), 1) + # Add Wire with BuildLine() as wire: Polyline((0, 0, 0), (1, 1, 1), (2, 0, 0), (3, 1, 1)) @@ -111,6 +130,10 @@ def test_errors(self): with self.assertRaises(RuntimeError): add(Edge.make_line((0, 0, 0), (1, 1, 1))) + with BuildPart() as test: + with self.assertRaises(ValueError): + add(Box(1, 1, 1), rotation=90) + def test_unsupported_builder(self): with self.assertRaises(RuntimeError): with _TestBuilder(): @@ -141,93 +164,16 @@ def test_multiple_faces(self): add(faces) self.assertEqual(len(multiple.pending_faces), 16) - -class TestOffset(unittest.TestCase): - def test_single_line_offset(self): - with BuildLine() as test: - Line((0, 0), (1, 0)) - offset(amount=1) - self.assertAlmostEqual(test.wires()[0].length, 2 + 2 * pi, 5) - - def test_line_offset(self): - with BuildSketch() as test: - with BuildLine(): - l = Line((0, 0), (1, 0)) - Line(l @ 1, (1, 1)) - offset(amount=1) - make_face() - self.assertAlmostEqual(test.sketch.area, pi * 1.25 + 3, 5) - - def test_line_offset(self): - with BuildSketch() as test: - with BuildLine() as line: - l = Line((0, 0), (1, 0)) - Line(l @ 1, (1, 1)) - offset(line.line.edges(), 1) - make_face() - self.assertAlmostEqual(test.sketch.area, pi * 1.25 + 3, 5) - - def test_face_offset(self): - with BuildSketch() as test: + def test_add_builder(self): + with BuildSketch() as s1: Rectangle(1, 1) - offset(amount=1, kind=Kind.INTERSECTION) - self.assertAlmostEqual(test.sketch.area, 9, 5) - - def test_box_offset(self): - with BuildPart() as test: - Box(10, 10, 10) - offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) - self.assertAlmostEqual(test.part.volume, 10**3 - 8**3, 5) - - def test_box_offset_with_opening(self): - with BuildPart() as test: - Box(10, 10, 10) - offset( - amount=-1, - openings=test.faces() >> Axis.Z, - kind=Kind.INTERSECTION, - ) - self.assertAlmostEqual(test.part.volume, 10**3 - 8**2 * 9, 5) - - with BuildPart() as test: - Box(10, 10, 10) - offset( - amount=-1, - openings=test.faces().sort_by(Axis.Z)[-1], - kind=Kind.INTERSECTION, - ) - self.assertAlmostEqual(test.part.volume, 10**3 - 8**2 * 9, 5) - - def test_box_offset_combinations(self): - with BuildPart() as o1: - Box(4, 4, 4) - offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.REPLACE) - self.assertAlmostEqual(o1.part.volume, 2**3, 5) - with BuildPart() as o2: - Box(4, 4, 4) - offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.REPLACE) - self.assertAlmostEqual(o2.part.volume, 6**3, 5) - - with BuildPart() as o3: - Box(4, 4, 4) - offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) - self.assertAlmostEqual(o3.part.volume, 4**3 - 2**3, 5) - - with BuildPart() as o4: - Box(4, 4, 4) - offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.ADD) - self.assertAlmostEqual(o4.part.volume, 6**3, 5) - - with BuildPart() as o5: - Box(4, 4, 4) - offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.ADD) - self.assertAlmostEqual(o5.part.volume, 4**3, 5) + with BuildSketch() as s2: + with Locations((1, 0)): + Rectangle(1, 1) + add(s1) - with BuildPart() as o6: - Box(4, 4, 4) - offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) - self.assertAlmostEqual(o6.part.volume, 0, 5) + self.assertAlmostEqual(s2.sketch.area, 2, 5) class BoundingBoxTests(unittest.TestCase): @@ -236,7 +182,7 @@ def test_boundingbox_to_sketch(self): with BuildSketch() as mickey: Circle(10) with BuildSketch(mode=Mode.PRIVATE) as bb: - bounding_box(mickey.faces()) + bounding_box(mickey.faces(), mode=Mode.ADD) ears = (bb.vertices() > Axis.Y)[:-2] with Locations(*ears): Circle(7) @@ -250,7 +196,7 @@ def test_boundingbox_to_sketch(self): def test_boundingbox_to_part(self): with BuildPart() as test: Sphere(1) - bounding_box(test.solids()) + bounding_box(test.solids(), mode=Mode.ADD) self.assertAlmostEqual(test.part.volume, 8, 5) with BuildPart() as test: Sphere(1) @@ -357,6 +303,21 @@ def test_error(self): pass +class LocationsTests(unittest.TestCase): + def test_push_locations(self): + with BuildPart(): + with Locations(Location(Vector())): + self.assertTupleAlmostEquals( + LocationList._get_context().locations[0].to_tuple()[0], (0, 0, 0), 5 + ) + + def test_errors(self): + with self.assertRaises(ValueError): + with BuildPart(): + with Locations(Edge.make_line((1, 0), (2, 0))): + pass + + class MirrorTests(unittest.TestCase): def test_mirror_line(self): edge = Edge.make_line((1, 0, 0), (2, 0, 0)) @@ -432,6 +393,206 @@ def test_changing_object_type(self): self.assertEqual(construction_face.geom_type(), "PLANE") +class OffsetTests(unittest.TestCase): + def test_single_line_offset(self): + with BuildLine() as test: + Line((0, 0), (1, 0)) + offset(amount=1) + self.assertAlmostEqual(test.wires()[0].length, 2 + 2 * pi, 5) + + def test_line_offset(self): + with BuildSketch() as test: + with BuildLine(): + l = Line((0, 0), (1, 0)) + Line(l @ 1, (1, 1)) + offset(amount=1) + make_face() + self.assertAlmostEqual(test.sketch.area, pi * 1.25 + 3, 5) + + def test_line_offset(self): + with BuildSketch() as test: + with BuildLine() as line: + l = Line((0, 0), (1, 0)) + Line(l @ 1, (1, 1)) + offset(line.line.edges(), 1) + make_face() + self.assertAlmostEqual(test.sketch.area, pi * 1.25 + 3, 5) + + def test_face_offset(self): + with BuildSketch() as test: + Rectangle(1, 1) + offset(amount=1, kind=Kind.INTERSECTION) + self.assertAlmostEqual(test.sketch.area, 9, 5) + + def test_box_offset(self): + with BuildPart() as test: + Box(10, 10, 10) + offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) + self.assertAlmostEqual(test.part.volume, 10**3 - 8**3, 5) + + def test_box_offset_with_opening(self): + with BuildPart() as test: + Box(10, 10, 10) + offset( + amount=-1, + openings=test.faces() >> Axis.Z, + kind=Kind.INTERSECTION, + ) + self.assertAlmostEqual(test.part.volume, 10**3 - 8**2 * 9, 5) + + with BuildPart() as test: + Box(10, 10, 10) + offset( + amount=-1, + openings=test.faces().sort_by(Axis.Z)[-1], + kind=Kind.INTERSECTION, + ) + self.assertAlmostEqual(test.part.volume, 10**3 - 8**2 * 9, 5) + + def test_box_offset_combinations(self): + with BuildPart() as o1: + Box(4, 4, 4) + offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.REPLACE) + self.assertAlmostEqual(o1.part.volume, 2**3, 5) + + with BuildPart() as o2: + Box(4, 4, 4) + offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.REPLACE) + self.assertAlmostEqual(o2.part.volume, 6**3, 5) + + with BuildPart() as o3: + Box(4, 4, 4) + offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) + self.assertAlmostEqual(o3.part.volume, 4**3 - 2**3, 5) + + with BuildPart() as o4: + Box(4, 4, 4) + offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.ADD) + self.assertAlmostEqual(o4.part.volume, 6**3, 5) + + with BuildPart() as o5: + Box(4, 4, 4) + offset(amount=-1, kind=Kind.INTERSECTION, mode=Mode.ADD) + self.assertAlmostEqual(o5.part.volume, 4**3, 5) + + with BuildPart() as o6: + Box(4, 4, 4) + offset(amount=1, kind=Kind.INTERSECTION, mode=Mode.SUBTRACT) + self.assertAlmostEqual(o6.part.volume, 0, 5) + + def test_offset_algebra_wire(self): + pts = [ + (11.421, 10.15), + (11.421, 84.582), + (19.213, 118.618), + (17.543, 127.548), + (-10.675, 127.548), + (-10.675, 118.618), + (-19.313, 118.618), + ] + line = FilletPolyline(*pts, radius=3.177) + self.assertEqual(len(line.edges()), 11) + o_line = offset(line, amount=3.177) + self.assertEqual(len(o_line.edges()), 19) + + def test_offset_bad_type(self): + with self.assertRaises(TypeError): + offset(Vertex(), amount=1) + + +class PolarLocationsTests(unittest.TestCase): + def test_errors(self): + with self.assertRaises(ValueError): + with BuildPart(): + with PolarLocations(10, 0, 360, 0): + pass + + +class ProjectionTests(unittest.TestCase): + def test_project_to_sketch1(self): + with BuildPart() as loaf_s: + with BuildSketch(Plane.YZ) as profile: + Rectangle(10, 15) + fillet(profile.vertices().group_by(Axis.Y)[-1], 2) + extrude(amount=15, both=True) + ref_s_pnts = loaf_s.vertices().group_by(Axis.X)[-1].group_by(Axis.Z)[0] + origin = (ref_s_pnts[0].to_vector() + ref_s_pnts[1].to_vector()) / 2 + x_dir = ref_s_pnts.sort_by(Axis.Y)[-1] - ref_s_pnts.sort_by(Axis.Y)[0] + workplane_s = project_workplane(origin, x_dir, (1, 0, 1), 30) + with BuildSketch(workplane_s) as projection_s: + project(loaf_s.part.faces().sort_by(Axis.X)[-1]) + + self.assertAlmostEqual(projection_s.sketch.area, 104.85204601097809, 5) + self.assertEqual(len(projection_s.edges()), 6) + + def test_project_to_sketch2(self): + with BuildPart() as test2: + Box(4, 4, 1) + with BuildSketch() as c: + Rectangle(1, 1) + project() + extrude(amount=1) + self.assertAlmostEqual(test2.part.volume, 4 * 4 * 1 + 1 * 1 * 1, 5) + + def test_project_point(self): + pnt: Vector = project(Vector(1, 2, 3), Plane.XY)[0] + self.assertTupleAlmostEquals(pnt.to_tuple(), (1, 2, 0), 5) + pnt: Vector = project(Vertex(1, 2, 3), Plane.XZ)[0] + self.assertTupleAlmostEquals(pnt.to_tuple(), (1, 3, 0), 5) + with BuildSketch(Plane.YZ) as s1: + pnt = project(Vertex(1, 2, 3), mode=Mode.PRIVATE)[0] + self.assertTupleAlmostEquals(pnt.to_tuple(), (2, 3, 0), 5) + + def test_multiple_results(self): + with BuildLine() as l1: + project( + [ + Edge.make_line((0, 1, 2), (3, 4, 5)), + Edge.make_line((-1, 2, 3), (-4, 5, -6)), + ] + ) + self.assertEqual(len(l1.edges()), 2) + + def test_project_errors(self): + with self.assertRaises(ValueError): + project(Vertex(1, 2, 3)) + + with self.assertRaises(ValueError): + project(workplane=Plane.XY, target=Box(1, 1, 1)) + + with self.assertRaises(ValueError): + project( + Box(1, 1, 1).vertices().group_by(Axis.Z), + workplane=Plane.XY, + target=Box(1, 1, 1), + ) + with self.assertRaises(ValueError): + project( + Box(1, 1, 1).vertices().group_by(Axis.Z), + target=Box(1, 1, 1), + ) + + with self.assertRaises(ValueError): + with BuildPart(): + pnt = project(Vertex(1, 2, 3))[0] + + with self.assertRaises(ValueError): + with BuildSketch(Plane.YZ): + pnt = project(Vertex(1, 2, 3))[0] + + with self.assertRaises(ValueError): + with BuildLine(): + pnt = project(Vertex(1, 2, 3))[0] + + +class RectangularArrayTests(unittest.TestCase): + def test_errors(self): + with self.assertRaises(ValueError): + with BuildPart(): + with GridLocations(5, 5, 0, 1): + pass + + class ScaleTests(unittest.TestCase): def test_line(self): with BuildLine() as test: @@ -464,35 +625,112 @@ def test_error_checking(self): scale(by="a") -class PolarLocationsTests(unittest.TestCase): - def test_errors(self): - with self.assertRaises(ValueError): - with BuildPart(): - with PolarLocations(10, 0, 360, 0): - pass +class TestSweep(unittest.TestCase): + def test_single_section(self): + with BuildPart() as test: + with BuildLine(): + Line((0, 0, 0), (0, 0, 10)) + with BuildSketch(): + Rectangle(2, 2) + sweep() + self.assertAlmostEqual(test.part.volume, 40, 5) + + def test_multi_section(self): + segment_count = 6 + with BuildPart() as handle: + with BuildLine() as handle_center_line: + Spline( + (-10, 0, 0), + (0, 0, 5), + (10, 0, 0), + tangents=((0, 0, 1), (0, 0, -1)), + tangent_scalars=(1.5, 1.5), + ) + handle_path = handle_center_line.wires()[0] + for i in range(segment_count + 1): + with BuildSketch( + Plane( + origin=handle_path @ (i / segment_count), + z_dir=handle_path % (i / segment_count), + ) + ) as section: + if i % segment_count == 0: + Circle(1) + else: + Rectangle(1, 2) + fillet(section.vertices(), radius=0.2) + # Create the handle by sweeping along the path + sweep(multisection=True) + self.assertAlmostEqual(handle.part.volume, 54.11246334691092, 5) + + def test_passed_parameters(self): + with BuildLine() as path: + Line((0, 0, 0), (0, 0, 10)) + with BuildSketch() as section: + Rectangle(2, 2) + with BuildPart() as test: + sweep(section.faces(), path=path.wires()[0]) + self.assertAlmostEqual(test.part.volume, 40, 5) + + def test_binormal(self): + with BuildPart() as sweep_binormal: + with BuildLine() as path: + Spline((0, 0, 0), (-12, 8, 10), tangents=[(0, 0, 1), (-1, 0, 0)]) + with BuildLine(mode=Mode.PRIVATE) as binormal: + Line((-5, 5), (-8, 10)) + with BuildSketch() as section: + Rectangle(4, 6) + sweep(binormal=binormal.edges()[0]) + + end_face: Face = ( + sweep_binormal.faces().filter_by(GeomType.PLANE).sort_by(Axis.X)[0] + ) + face_binormal_axis = Axis( + end_face.center(), binormal.edges()[0] @ 1 - end_face.center() + ) + face_normal_axis = Axis(end_face.center(), end_face.normal_at()) + self.assertTrue(face_normal_axis.is_normal(face_binormal_axis)) + def test_sweep_edge(self): + arc = PolarLine((1, 0), 2, 135) + arc_path = PolarLine(arc @ 1, 10, 45) + swept = sweep(sections=arc, path=arc_path) + self.assertTrue(isinstance(swept, Sketch)) + self.assertAlmostEqual(swept.area, 2 * 10, 5) -class LocationsTests(unittest.TestCase): - def test_push_locations(self): - with BuildPart(): - with Locations(Location(Vector())): - self.assertTupleAlmostEquals( - LocationList._get_context().locations[0].to_tuple()[0], (0, 0, 0), 5 - ) + def test_no_path(self): + with self.assertRaises(ValueError): + sweep(PolarLine((1, 0), 2, 135)) - def test_errors(self): + def test_no_sections(self): with self.assertRaises(ValueError): - with BuildPart(): - with Locations(Edge.make_line((1, 0), (2, 0))): - pass + sweep(path=PolarLine((1, 0), 2, 135)) + def test_path_from_edges(self): + d = 8.5 + h = 4.65 + lip = 0.5 -class RectangularArrayTests(unittest.TestCase): - def test_errors(self): + with BuildPart() as p: + with BuildSketch() as sk: + Rectangle(d * 3, d) + fillet(sk.vertices().group_by(Axis.X)[0], d / 2) + extrude(amount=4.65) + topedgs = ( + p.part.edges().group_by(Axis.Z)[2].sort_by(Axis.X)[0:3].sort_by(Axis.Y) + ) + with BuildSketch(Plane.ZY.offset(-d * 3 / 2)) as sk2: + with Locations((h, -d / 2)): + Rectangle(2 * lip, 2 * lip, align=(Align.CENTER, Align.CENTER)) + sweep(sections=sk2.sketch, path=topedgs, mode=Mode.SUBTRACT) + + self.assertTrue(p.part.is_valid()) + + def test_path_error(self): + e1 = Edge.make_line((0, 0), (1, 0)) + e2 = Edge.make_line((2, 0), (3, 0)) with self.assertRaises(ValueError): - with BuildPart(): - with GridLocations(5, 5, 0, 1): - pass + sweep(sections=Edge.make_line((0, 0), (0, 1)), path=ShapeList([e1, e2])) if __name__ == "__main__": diff --git a/tests/test_build_line.py b/tests/test_build_line.py index 27c86d3d..d28f05db 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -26,7 +26,7 @@ """ import unittest -from math import sqrt +from math import sqrt, pi from build123d import * @@ -120,11 +120,63 @@ def test_elliptical_center_arc(self): self.assertLessEqual(bbox.max.X, 10) self.assertLessEqual(bbox.max.Y, 5) + e1 = EllipticalCenterArc((0, 0), 10, 5, 0, 180) + bbox = e1.bounding_box() + self.assertGreaterEqual(bbox.min.X, -10) + self.assertGreaterEqual(bbox.min.Y, 0) + self.assertLessEqual(bbox.max.X, 10) + self.assertLessEqual(bbox.max.Y, 5) + + def test_filletpolyline(self): + with BuildLine(Plane.YZ): + p = FilletPolyline( + (0, 0, 0), (0, 10, 2), (0, 10, 10), (5, 20, 10), radius=2 + ) + self.assertEqual(len(p.edges()), 5) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 2) + self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 3) + + with BuildLine(Plane.YZ): + p = FilletPolyline( + (0, 0, 0), (0, 0, 10), (10, 2, 10), (10, 0, 0), radius=2, close=True + ) + self.assertEqual(len(p.edges()), 8) + self.assertEqual(len(p.edges().filter_by(GeomType.CIRCLE)), 4) + self.assertEqual(len(p.edges().filter_by(GeomType.LINE)), 4) + + with self.assertRaises(ValueError): + FilletPolyline((0, 0), (1, 0), radius=0.1) + with self.assertRaises(ValueError): + FilletPolyline((0, 0), (1, 0), (1, 1), radius=-1) + + def test_intersecting_line(self): + with BuildLine(): + l1 = Line((0, 0), (10, 0)) + l2 = IntersectingLine((5, 10), (0, -1), l1) + self.assertAlmostEqual(l2.length, 10, 5) + + l3 = Line((0, 0), (10, 10)) + l4 = IntersectingLine((0, 10), (1, -1), l3) + self.assertTupleAlmostEquals((l4 @ 1).to_tuple(), (5, 5, 0), 5) + + with self.assertRaises(ValueError): + IntersectingLine((0, 10), (1, 1), l3) + def test_jern_arc(self): with BuildLine() as jern: JernArc((1, 0), (0, 1), 1, 90) self.assertTupleAlmostEquals((jern.edges()[0] @ 1).to_tuple(), (0, 1, 0), 5) + with BuildLine() as l: + l1 = JernArc(start=(0, 0, 0), tangent=(1, 0, 0), radius=1, arc_size=360) + self.assertTrue(l1.is_closed()) + circle_face = Face.make_from_wires(l1) + self.assertAlmostEqual(circle_face.area, pi, 5) + self.assertTupleAlmostEquals(circle_face.center().to_tuple(), (0, 1, 0), 5) + + l1 = JernArc((0, 0), (1, 0), 1, 90) + self.assertTupleAlmostEquals((l1 @ 1).to_tuple(), (1, 1, 0), 5) + def test_polar_line(self): """Test 2D and 3D polar lines""" with BuildLine() as bl: @@ -153,12 +205,49 @@ def test_polar_line(self): PolarLine((0, 0), 1, angle=30, length_mode=LengthMode.VERTICAL) self.assertTupleAlmostEquals((bl.edges()[0] @ 1).to_tuple(), (sqrt(3), 0, 1), 5) + l1 = PolarLine((0, 0), 10, direction=(1, 1)) + self.assertTupleAlmostEquals((l1 @ 1).to_tuple(), (10, 10, 0), 5) + + with self.assertRaises(ValueError): + PolarLine((0, 0), 1) + def test_spline(self): """Test spline with no tangents""" with BuildLine() as test: Spline((0, 0), (1, 1), (2, 0)) self.assertTupleAlmostEquals((test.edges()[0] @ 1).to_tuple(), (2, 0, 0), 5) + def test_radius_arc(self): + """Test center arc as arc and circle""" + with BuildSketch() as s: + c = Circle(10) + + e = c.edges()[0] + r = e.radius + p1, p2 = e @ 0.3, e @ 0.9 + + with BuildLine() as l: + arc1 = RadiusArc(p1, p2, r) + self.assertAlmostEqual(arc1.length, 2 * r * pi * 0.4, 6) + self.assertAlmostEqual(arc1.bounding_box().max.X, c.bounding_box().max.X) + + arc2 = RadiusArc(p1, p2, r, short_sagitta=False) + self.assertAlmostEqual(arc2.length, 2 * r * pi * 0.6, 6) + self.assertAlmostEqual(arc2.bounding_box().min.X, c.bounding_box().min.X) + + arc3 = RadiusArc(p1, p2, -r) + self.assertAlmostEqual(arc3.length, 2 * r * pi * 0.4, 6) + self.assertGreater(arc3.bounding_box().min.X, c.bounding_box().min.X) + self.assertLess(arc3.bounding_box().min.X, c.bounding_box().max.X) + + arc4 = RadiusArc(p1, p2, -r, short_sagitta=False) + self.assertAlmostEqual(arc4.length, 2 * r * pi * 0.6, 6) + self.assertGreater(arc4.bounding_box().max.X, c.bounding_box().max.X) + + def test_sagitta_arc(self): + l1 = SagittaArc((0, 0), (1, 0), 0.1) + self.assertAlmostEqual((l1 @ 0.5).Y, 0.1, 5) + def test_center_arc(self): """Test center arc as arc and circle""" with BuildLine() as arc: @@ -169,6 +258,17 @@ def test_center_arc(self): self.assertTupleAlmostEquals( (arc.edges()[0] @ 0).to_tuple(), (arc.edges()[0] @ 1).to_tuple(), 5 ) + with BuildLine(Plane.XZ) as arc: + CenterArc((0, 0), 10, 0, 360) + self.assertTrue(Face.make_from_wires(arc.wires()[0]).is_coplanar(Plane.XZ)) + + with BuildLine(Plane.XZ) as arc: + CenterArc((-100, 0), 100, -45, 90) + self.assertTupleAlmostEquals((arc.edges()[0] @ 0.5).to_tuple(), (0, 0, 0), 5) + + arc = CenterArc((-100, 0), 100, 0, 360) + self.assertTrue(Face.make_from_wires(arc.wires()[0]).is_coplanar(Plane.XY)) + self.assertTupleAlmostEquals(arc.bounding_box().max, (0, 100, 0), 5) def test_polyline(self): """Test edge generation and close""" diff --git a/tests/test_build_part.py b/tests/test_build_part.py index 1b14dead..6ad2d8f1 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -53,6 +53,23 @@ def test_align(self): self.assertLessEqual(bbox.max.Z, 0) +class TestMakeBrakeFormed(unittest.TestCase): + def test_make_brake_formed(self): + # TODO: Fix so this test doesn't raise a DeprecationWarning from NumPy + with BuildPart() as bp: + with BuildLine() as bl: + Polyline((0, 0), (5, 6), (10, 1)) + fillet(bl.vertices(), 1) + make_brake_formed(thickness=0.5, station_widths=[1, 2, 3, 4]) + self.assertTrue(bp.part.volume > 0) + self.assertAlmostEqual(bp.part.bounding_box().max.Z, 4, 2) + self.assertEqual(len(bp.faces().filter_by(GeomType.PLANE, reverse=True)), 3) + + outline = FilletPolyline((0, 0), (5, 6), (10, 1), radius=1) + sheet_metal = make_brake_formed(thickness=0.5, station_widths=1, line=outline) + self.assertAlmostEqual(sheet_metal.bounding_box().max.Z, 1, 2) + + class TestBuildPart(unittest.TestCase): """Test the BuildPart Builder derived class""" @@ -257,6 +274,12 @@ def test_extrude_face(self): extrude(square.sketch, amount=10) self.assertAlmostEqual(box.part.volume, 10**3, 5) + def test_extrude_non_planar_face(self): + cyl = Cylinder(1, 2) + npf = cyl.split(Plane.XZ).faces().filter_by(GeomType.PLANE, reverse=True)[0] + test_solid = extrude(npf, amount=3, dir=(0, 1, 0)) + self.assertAlmostEqual(test_solid.volume, 2 * 2 * 3, 5) + class TestHole(unittest.TestCase): def test_fixed_depth(self): @@ -363,14 +386,14 @@ class TestSection(unittest.TestCase): def test_circle(self): with BuildPart() as test: Sphere(10) - section() - self.assertAlmostEqual(test.faces()[-1].area, 100 * pi, 5) + s = section() + self.assertAlmostEqual(s.area, 100 * pi, 5) def test_custom_plane(self): with BuildPart() as test: Sphere(10) - section(section_by=Plane.XZ) - self.assertAlmostEqual(test.faces().filter_by(Axis.Y)[-1].area, 100 * pi, 5) + s = section(section_by=Plane.XZ) + self.assertAlmostEqual(s.area, 100 * pi, 5) class TestSplit(unittest.TestCase): @@ -393,71 +416,19 @@ def test_custom_plane(self): self.assertAlmostEqual(test.part.volume, (2 / 3) * 1000 * pi, 5) -class TestSweep(unittest.TestCase): - def test_single_section(self): - with BuildPart() as test: - with BuildLine(): - Line((0, 0, 0), (0, 0, 10)) +class TestThicken(unittest.TestCase): + def test_thicken(self): + with BuildPart() as bp: with BuildSketch(): - Rectangle(2, 2) - sweep() - self.assertAlmostEqual(test.part.volume, 40, 5) - - def test_multi_section(self): - segment_count = 6 - with BuildPart() as handle: - with BuildLine() as handle_center_line: - Spline( - (-10, 0, 0), - (0, 0, 5), - (10, 0, 0), - tangents=((0, 0, 1), (0, 0, -1)), - tangent_scalars=(1.5, 1.5), - ) - handle_path = handle_center_line.wires()[0] - for i in range(segment_count + 1): - with BuildSketch( - Plane( - origin=handle_path @ (i / segment_count), - z_dir=handle_path % (i / segment_count), - ) - ) as section: - if i % segment_count == 0: - Circle(1) - else: - Rectangle(1, 2) - fillet(section.vertices(), radius=0.2) - # Create the handle by sweeping along the path - sweep(multisection=True) - self.assertAlmostEqual(handle.part.volume, 54.11246334691092, 5) - - def test_passed_parameters(self): - with BuildLine() as path: - Line((0, 0, 0), (0, 0, 10)) - with BuildSketch() as section: - Rectangle(2, 2) - with BuildPart() as test: - sweep(section.faces(), path=path.wires()[0]) - self.assertAlmostEqual(test.part.volume, 40, 5) - - def test_binormal(self): - with BuildPart() as sweep_binormal: - with BuildLine() as path: - Spline((0, 0, 0), (-12, 8, 10), tangents=[(0, 0, 1), (-1, 0, 0)]) - with BuildLine(mode=Mode.PRIVATE) as binormal: - Line((-5, 5), (-8, 10)) - with BuildSketch() as section: - Rectangle(4, 6) - sweep(binormal=binormal.edges()[0]) - - end_face: Face = ( - sweep_binormal.faces().filter_by(GeomType.PLANE).sort_by(Axis.X)[0] - ) - face_binormal_axis = Axis( - end_face.center(), binormal.edges()[0] @ 1 - end_face.center() + RectangleRounded(10, 10, 1) + thicken(amount=1) + self.assertAlmostEqual(bp.part.bounding_box().max.Z, 1, 5) + + non_planar = Sphere(1).faces()[0] + outer_sphere = thicken(non_planar, amount=0.1) + self.assertAlmostEqual( + outer_sphere.volume, (4 / 3) * pi * (1.1**3 - 1**3), 5 ) - face_normal_axis = Axis(end_face.center(), end_face.normal_at()) - self.assertTrue(face_normal_axis.is_normal(face_binormal_axis)) class TestTorus(unittest.TestCase): @@ -467,5 +438,21 @@ def test_simple_torus(self): self.assertAlmostEqual(test.part.volume, pi * 100 * 2 * pi * 100, 5) +class TestWedge(unittest.TestCase): + def test_simple_wedge(self): + wedge = Wedge(1, 1, 1, 0, 0, 2, 5) + self.assertAlmostEqual(wedge.volume, 4.833333333333334, 5) + + def test_invalid_wedge(self): + with self.assertRaises(ValueError): + Wedge(0, 1, 1, 0, 0, 2, 5) + + with self.assertRaises(ValueError): + Wedge(1, 0, 1, 0, 0, 2, 5) + + with self.assertRaises(ValueError): + Wedge(1, 1, 0, 0, 0, 2, 5) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_build_sketch.py b/tests/test_build_sketch.py index 60c21d42..f9e38709 100644 --- a/tests/test_build_sketch.py +++ b/tests/test_build_sketch.py @@ -133,6 +133,42 @@ def test_changing_geometry(self): self.assertAlmostEqual(s.sketch.area, 4, 5) +class TestUpSideDown(unittest.TestCase): + def test_flip_face(self): + f1 = Face.make_from_wires( + Wire.make_polygon([(1, 0), (1.5, 0.5), (1, 2), (3, 1), (2, 0), (1, 0)]) + ) + f1 = ( + fillet(f1.vertices()[2:4], 0.2) + - Pos(1.8, 1.2, 0) * Rectangle(0.2, 0.4) + - Pos(2, 0.5, 0) * Circle(0.2) + ).faces()[0] + self.assertTrue(f1.normal_at().Z < 0) # Up-side-down + + f2 = Face.make_from_wires( + Wire.make_polygon([(1, 0), (1.5, -1), (2, -1), (2, 0), (1, 0)]) + ) + self.assertTrue(f2.normal_at().Z > 0) # Right-side-up + with BuildSketch() as flip_test: + add(f1) + add(f2) + self.assertEqual(len(flip_test.faces()), 1) # Face flip and combined + + def test_make_hull_flipped(self): + with BuildSketch() as base_plan: + Circle(55 / 2) + with Locations((0, 125)): + Circle(30 / 2) + base_hull = make_hull(mode=Mode.PRIVATE) + for face in base_hull.faces(): + self.assertTrue(face.normal_at().Z > 0) + + def test_make_face_flipped(self): + wire = Wire.make_polygon([(0, 0), (1, 1), (2, 0)]) + sketch = make_face(wire.edges()) + self.assertTrue(sketch.faces()[0].normal_at().Z > 0) + + class TestBuildSketchExceptions(unittest.TestCase): """Test exception handling""" @@ -350,6 +386,13 @@ def test_add_multiple(self): Circle(1) self.assertAlmostEqual(sum([f.area for f in test.faces()]), 2 * pi, 5) + def test_make_face(self): + with self.assertRaises(ValueError): + with BuildSketch(): + make_face() + with self.assertRaises(ValueError): + make_face() + def test_make_hull(self): """Test hull from pending edges and passed edges""" with BuildSketch() as test: @@ -369,6 +412,19 @@ def test_make_hull(self): with self.assertRaises(ValueError): with BuildSketch(): make_hull() + with self.assertRaises(ValueError): + make_hull() + + def test_trace(self): + with BuildSketch() as test: + with BuildLine(): + Line((0, 0), (10, 0)) + trace(line_width=1) + self.assertEqual(len(test.faces()), 1) + self.assertAlmostEqual(test.sketch.area, 10, 5) + + with self.assertRaises(ValueError): + trace() if __name__ == "__main__": diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py index c5736935..b3a65b1d 100644 --- a/tests/test_direct_api.py +++ b/tests/test_direct_api.py @@ -2,6 +2,7 @@ import copy import math import os +import platform import random import re from typing import Optional @@ -27,17 +28,25 @@ from build123d.build_common import GridLocations, Locations, PolarLocations from build123d.build_enums import ( Align, + AngularDirection, CenterOf, GeomType, + Keep, Kind, + Mode, PositionMode, + Side, SortBy, Until, ) from build123d.build_part import BuildPart from build123d.operations_part import extrude -from build123d.objects_part import Box +from build123d.operations_sketch import make_face +from build123d.operations_generic import fillet, add +from build123d.objects_part import Box, Cylinder +from build123d.objects_curve import Polyline from build123d.build_sketch import BuildSketch +from build123d.build_line import BuildLine from build123d.objects_sketch import Circle, Rectangle, RegularPolygon from build123d.geometry import ( Axis, @@ -45,29 +54,30 @@ Color, Location, Matrix, + Pos, Rotation, Vector, VectorLike, ) -from build123d.importers import import_brep, import_step, import_stl, import_svg +from build123d.importers import import_brep, import_step, import_stl +from build123d.mesher import Mesher from build123d.topology import ( - BallJoint, Compound, - CylindricalJoint, Edge, Face, - LinearJoint, Plane, - RevoluteJoint, - RigidJoint, Shape, + ShapeList, Shell, Solid, Vertex, Wire, edges_to_wires, polar, + new_edges, + delta, ) +from build123d.jupyter_tools import display DEG2RAD = math.pi / 180 RAD2DEG = 180 / math.pi @@ -187,6 +197,30 @@ def test_do_children_intersect(self): class TestAxis(DirectApiTestCase): """Test the Axis class""" + def test_axis_init(self): + test_axis = Axis((1, 2, 3), (0, 0, 1)) + self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis((1, 2, 3), direction=(0, 0, 1)) + self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(origin=(1, 2, 3), direction=(0, 0, 1)) + self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(Edge.make_line((1, 2, 3), (1, 2, 4))) + self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) + + test_axis = Axis(edge=Edge.make_line((1, 2, 3), (1, 2, 4))) + self.assertVectorAlmostEquals(test_axis.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(test_axis.direction, (0, 0, 1), 5) + + with self.assertRaises(ValueError): + Axis("one", "up") + def test_axis_from_occt(self): occt_axis = gp_Ax1(gp_Pnt(1, 1, 1), gp_Dir(0, 1, 0)) test_axis = Axis.from_occt(occt_axis) @@ -207,7 +241,7 @@ def test_axis_copy(self): def test_axis_to_location(self): # TODO: Verify this is correct - x_location = Axis.X.to_location() + x_location = Axis.X.location self.assertTrue(isinstance(x_location, Location)) self.assertVectorAlmostEquals(x_location.position, (0, 0, 0), 5) self.assertVectorAlmostEquals(x_location.orientation, (0, 90, 180), 5) @@ -315,6 +349,12 @@ def test_center_of_boundbox(self): def test_combined_center_of_boundbox(self): pass + def test_clean_boundbox(self): + s = Solid.make_sphere(3) + self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5) + s.mesh(1e-3) + self.assertVectorAlmostEquals(s.bounding_box().size, (6, 6, 6), 5) + # def test_to_solid(self): # bbox = Solid.make_sphere(1).bounding_box() # self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5) @@ -560,7 +600,7 @@ def test_name1(self): self.assertEqual(c.wrapped.GetRGB().Red(), 0.0) self.assertEqual(c.wrapped.GetRGB().Green(), 0.0) self.assertEqual(c.wrapped.GetRGB().Blue(), 1.0) - self.assertEqual(c.wrapped.Alpha(), 0.0) + self.assertEqual(c.wrapped.Alpha(), 1.0) def test_name2(self): c = Color("blue", alpha=0.5) @@ -666,6 +706,18 @@ def test_center(self): with self.assertRaises(ValueError): test_compound.center(CenterOf.GEOMETRY) + def test_triad(self): + triad = Compound.make_triad(10) + bbox = triad.bounding_box() + self.assertGreater(bbox.min.X, -10 / 8) + self.assertLess(bbox.min.X, 0) + self.assertGreater(bbox.min.Y, -10 / 8) + self.assertLess(bbox.min.Y, 0) + self.assertGreater(bbox.min.Y, -10 / 8) + self.assertAlmostEqual(bbox.min.Z, 0, 4) + self.assertLess(bbox.size.Z, 12.5) + self.assertEqual(triad.volume, 0) + class TestEdge(DirectApiTestCase): def test_close(self): @@ -674,6 +726,36 @@ def test_close(self): ) self.assertAlmostEqual(Edge.make_circle(1).close().length, 2 * math.pi, 5) + def test_make_half_circle(self): + half_circle = Edge.make_circle(radius=1, start_angle=0, end_angle=180) + self.assertVectorAlmostEquals(half_circle.start_point(), (1, 0, 0), 3) + self.assertVectorAlmostEquals(half_circle.end_point(), (-1, 0, 0), 3) + + def test_make_half_circle2(self): + half_circle = Edge.make_circle(radius=1, start_angle=270, end_angle=90) + self.assertVectorAlmostEquals(half_circle.start_point(), (0, -1, 0), 3) + self.assertVectorAlmostEquals(half_circle.end_point(), (0, 1, 0), 3) + + def test_make_clockwise_half_circle(self): + half_circle = Edge.make_circle( + radius=1, + start_angle=180, + end_angle=0, + angular_direction=AngularDirection.CLOCKWISE, + ) + self.assertVectorAlmostEquals(half_circle.end_point(), (1, 0, 0), 3) + self.assertVectorAlmostEquals(half_circle.start_point(), (-1, 0, 0), 3) + + def test_make_clockwise_half_circle2(self): + half_circle = Edge.make_circle( + radius=1, + start_angle=90, + end_angle=-90, + angular_direction=AngularDirection.CLOCKWISE, + ) + self.assertVectorAlmostEquals(half_circle.start_point(), (0, 1, 0), 3) + self.assertVectorAlmostEquals(half_circle.end_point(), (0, -1, 0), 3) + def test_arc_center(self): self.assertVectorAlmostEquals(Edge.make_ellipse(2, 1).arc_center, (0, 0, 0), 5) with self.assertRaises(ValueError): @@ -713,17 +795,6 @@ def test_distribute_locations(self): self.assertVectorAlmostEquals(locs[i].position, (x, 0, 0), 5) self.assertVectorAlmostEquals(locs[0].orientation, (0, 0, 0), 5) - # def test_overlaps(self): - # self.assertTrue( - # Edge.make_circle(10, end_angle=60).overlaps( - # Edge.make_circle(10, start_angle=30, end_angle=90) - # ) - # ) - # tolerance = 1e-4 - # self.assertFalse( - # Edge.make_line((-10,0),(0,0)).overlaps(Edge.make_line((1.1*tolerance,0),(10,0)), tolerance) - # ) - def test_to_wire(self): edge = Edge.make_line((0, 0, 0), (1, 1, 1)) for end in [0, 1]: @@ -761,6 +832,12 @@ def test_intersections(self): (-2.6861636507066047, 0, 0), 5, ) + line = Edge.make_line((1, -2), (1, 2)) + crosses = line.intersections(Plane.XY, Axis.X) + self.assertVectorAlmostEquals(crosses[0], (1, 0, 0), 5) + + with self.assertRaises(ValueError): + line.intersections(Plane.XY, Plane.YZ) def test_trim(self): line = Edge.make_line((-2, 0), (2, 0)) @@ -807,6 +884,30 @@ def test_distribute_locations2(self): ) self.assertVectorAlmostEquals(loc.orientation, (0, 0, 0), 5) + def test_find_tangent(self): + circle = Edge.make_circle(1) + parm = circle.find_tangent(135)[0] + self.assertVectorAlmostEquals( + circle @ parm, (math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 5 + ) + line = Edge.make_line((0, 0), (1, 1)) + parm = line.find_tangent(45)[0] + self.assertAlmostEqual(parm, 0, 5) + parm = line.find_tangent(0) + self.assertEqual(len(parm), 0) + + def test_param_at_point(self): + u = Edge.make_circle(1).param_at_point((0, 1)) + self.assertAlmostEqual(u, 0.25, 5) + + u = 0.3 + edge = Edge.make_line((0, 0), (34, 56)) + pnt = edge.position_at(u) + self.assertAlmostEqual(edge.param_at_point(pnt), u, 5) + + with self.assertRaises(ValueError): + edge.param_at_point((-1, 1)) + class TestFace(DirectApiTestCase): def test_make_surface_from_curves(self): @@ -970,6 +1071,9 @@ def test_is_inside(self): def test_import_stl(self): torus = Solid.make_torus(10, 1) + # exporter = Mesher() + # exporter.add_shape(torus) + # exporter.write("test_torus.stl") torus.export_stl("test_torus.stl") imported_torus = import_stl("test_torus.stl") # The torus from stl is tessellated therefore the areas will only be close @@ -983,6 +1087,96 @@ def test_is_coplanar(self): surface: Face = Solid.make_sphere(1).faces()[0] self.assertFalse(surface.is_coplanar(Plane.XY)) + def test_center_location(self): + square = Face.make_rect(1, 1, plane=Plane.XZ) + cl = square.center_location + self.assertVectorAlmostEquals(cl.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(cl.orientation, Plane.XZ.location.orientation, 5) + + def test_position_at(self): + square = Face.make_rect(2, 2, plane=Plane.XZ.offset(1)) + p = square.position_at(0.25, 0.75) + self.assertVectorAlmostEquals(p, (-0.5, -1.0, 0.5), 5) + + def test_make_surface(self): + corners = [Vector(x, y) for x in [-50.5, 50.5] for y in [-24.5, 24.5]] + net_exterior = Wire.make_wire( + [ + Edge.make_line(corners[3], corners[1]), + Edge.make_line(corners[1], corners[0]), + Edge.make_line(corners[0], corners[2]), + Edge.make_three_point_arc( + corners[2], + (corners[2] + corners[3]) / 2 - Vector(0, 0, 3), + corners[3], + ), + ] + ) + surface = Face.make_surface( + net_exterior, + surface_points=[Vector(0, 0, -5)], + ) + hole_flat = Wire.make_circle(10) + hole = hole_flat.project_to_shape(surface, (0, 0, -1))[0] + surface = Face.make_surface( + exterior=net_exterior, + surface_points=[Vector(0, 0, -5)], + interior_wires=[hole], + ) + self.assertTrue(surface.is_valid()) + self.assertEqual(surface.geom_type(), "BSPLINE") + bbox = surface.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -5.113393280136395), 5) + self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5) + + # With no surface point + surface = Face.make_surface(net_exterior) + bbox = surface.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-50.5, -24.5, -3), 5) + self.assertVectorAlmostEquals(bbox.max, (50.5, 24.5, 0), 5) + + # Exterior Edge + surface = Face.make_surface([Edge.make_circle(50)], surface_points=[(0, 0, -5)]) + bbox = surface.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-50, -50, -5), 5) + self.assertVectorAlmostEquals(bbox.max, (50, 50, 0), 5) + + def test_make_surface_error_checking(self): + with self.assertRaises(ValueError): + Face.make_surface(Edge.make_line((0, 0), (1, 0))) + + with self.assertRaises(RuntimeError): + Face.make_surface([Edge.make_line((0, 0), (1, 0))]) + + if platform.system() != "Darwin": + with self.assertRaises(RuntimeError): + Face.make_surface( + [Edge.make_circle(50)], surface_points=[(0, 0, -50), (0, 0, 50)] + ) + + with self.assertRaises(RuntimeError): + Face.make_surface( + [Edge.make_circle(50)], + interior_wires=[Wire.make_circle(5, Plane.XZ)], + ) + + def test_sweep(self): + edge = Edge.make_line((1, 0), (2, 0)) + path = Wire.make_circle(1) + circle_with_hole = Face.sweep(edge, path) + self.assertTrue(isinstance(circle_with_hole, Face)) + self.assertAlmostEqual(circle_with_hole.area, math.pi * (2**2 - 1**1), 5) + + def test_to_arcs(self): + with BuildSketch() as bs: + with BuildLine() as bl: + Polyline((0, 0), (1, 0), (1.5, 0.5), (2, 0), (2, 1), (0, 1), (0, 0)) + fillet(bl.vertices(), radius=0.1) + make_face() + smooth = bs.faces()[0] + fragmented = smooth.to_arcs() + self.assertLess(len(smooth.edges()), len(fragmented.edges())) + class TestFunctions(unittest.TestCase): def test_edges_to_wires(self): @@ -998,8 +1192,24 @@ def test_polar(self): self.assertAlmostEqual(pnt[0], math.sqrt(3) / 2, 5) self.assertAlmostEqual(pnt[1], 0.5, 5) + def test_new_edges(self): + c = Solid.make_cylinder(1, 5) + s = Solid.make_sphere(2) + s_minus_c = s - c + seams = new_edges(c, s, combined=s_minus_c) + self.assertEqual(len(seams), 1) + self.assertAlmostEqual(seams[0].radius, 1, 5) + + def test_delta(self): + cyl = Solid.make_cylinder(1, 5) + sph = Solid.make_sphere(2) + con = Solid.make_cone(2, 1, 2) + plug = delta([cyl, sph, con], [sph, con]) + self.assertEqual(len(plug), 1) + self.assertEqual(plug[0], cyl) -class TestImportExport(unittest.TestCase): + +class TestImportExport(DirectApiTestCase): def test_import_export(self): original_box = Solid.make_box(1, 1, 1) original_box.export_step("test_box.step") @@ -1015,233 +1225,30 @@ def test_import_export(self): with self.assertRaises(ValueError): step_box = import_step("test_box.step") + def test_import_stl(self): + # export solid + original_box = Solid.make_box(1, 2, 3) + exporter = Mesher() + exporter.add_shape(original_box) + exporter.write("test.stl") -class TestJoints(DirectApiTestCase): - def test_rigid_joint(self): - base = Solid.make_box(1, 1, 1) - j1 = RigidJoint("top", base, Location(Vector(0.5, 0.5, 1))) - fixed_top = Solid.make_box(1, 1, 1) - j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0))) - j1.connect_to(j2) - bbox = fixed_top.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5) - self.assertVectorAlmostEquals(bbox.max, (1, 1, 2), 5) - - self.assertVectorAlmostEquals(j2.symbol.location.position, (0.5, 0.5, 1), 6) - self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6) - - def test_revolute_joint_with_angle_reference(self): - revolute_base = Solid.make_cylinder(1, 1) - j1 = RevoluteJoint( - label="top", - to_part=revolute_base, - axis=Axis((0, 0, 1), (0, 0, 1)), - angle_reference=(1, 0, 0), - angular_range=(0, 180), - ) - fixed_top = Solid.make_box(1, 0.5, 1) - j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0))) - - j1.connect_to(j2, 90) - bbox = fixed_top.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-0.25, -0.5, 1), 5) - self.assertVectorAlmostEquals(bbox.max, (0.25, 0.5, 2), 5) - - self.assertVectorAlmostEquals(j2.symbol.location.position, (0, 0, 1), 6) - self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6) - self.assertEqual(len(j1.symbol.edges()), 2) - - def test_revolute_joint_without_angle_reference(self): - revolute_base = Solid.make_cylinder(1, 1) - j1 = RevoluteJoint( - label="top", - to_part=revolute_base, - axis=Axis((0, 0, 1), (0, 0, 1)), - ) - self.assertVectorAlmostEquals(j1.angle_reference, (1, 0, 0), 5) - - def test_revolute_joint_error_bad_angle_reference(self): - """Test that the angle_reference must be normal to the axis""" - revolute_base = Solid.make_cylinder(1, 1) - with self.assertRaises(ValueError): - RevoluteJoint( - "top", - revolute_base, - axis=Axis((0, 0, 1), (0, 0, 1)), - angle_reference=(1, 0, 1), - ) - - def test_revolute_joint_error_bad_angle(self): - """Test that the joint angle is within bounds""" - revolute_base = Solid.make_cylinder(1, 1) - j1 = RevoluteJoint("top", revolute_base, Axis.Z, angular_range=(0, 180)) - fixed_top = Solid.make_box(1, 0.5, 1) - j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0))) - with self.assertRaises(ValueError): - j1.connect_to(j2, 270) - - def test_revolute_joint_error_bad_joint_type(self): - """Test that the joint angle is within bounds""" - revolute_base = Solid.make_cylinder(1, 1) - j1 = RevoluteJoint("top", revolute_base, Axis.Z, (0, 180)) - fixed_top = Solid.make_box(1, 0.5, 1) - j2 = RevoluteJoint("bottom", fixed_top, Axis.Z, (0, 180)) - with self.assertRaises(TypeError): - j1.connect_to(j2, 0) - - def test_linear_rigid_joint(self): - base = Solid.make_box(1, 1, 1) - j1 = LinearJoint( - "top", to_part=base, axis=Axis((0, 0.5, 1), (1, 0, 0)), linear_range=(0, 1) - ) - fixed_top = Solid.make_box(1, 1, 1) - j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0))) - j1.connect_to(j2, 0.25) - bbox = fixed_top.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (-0.25, 0, 1), 5) - self.assertVectorAlmostEquals(bbox.max, (0.75, 1, 2), 5) - - self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6) - self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6) - - def test_linear_revolute_joint(self): - linear_base = Solid.make_box(1, 1, 1) - j1 = LinearJoint( - label="top", - to_part=linear_base, - axis=Axis((0, 0.5, 1), (1, 0, 0)), - linear_range=(0, 1), - ) - revolute_top = Solid.make_box(1, 0.5, 1).locate(Location((-0.5, -0.25, 0))) - j2 = RevoluteJoint( - label="top", - to_part=revolute_top, - axis=Axis((0, 0, 0), (0, 0, 1)), - angle_reference=(1, 0, 0), - angular_range=(0, 180), - ) - j1.connect_to(j2, position=0.25, angle=90) - - bbox = revolute_top.bounding_box() - self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5) - self.assertVectorAlmostEquals(bbox.max, (0.5, 1, 2), 5) - - self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6) - self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6) - self.assertEqual(len(j1.symbol.edges()), 2) - - # Test invalid position - with self.assertRaises(ValueError): - j1.connect_to(j2, position=5, angle=90) - - # Test invalid angle - with self.assertRaises(ValueError): - j1.connect_to(j2, position=0.5, angle=270) - - # Test invalid joint - with self.assertRaises(TypeError): - j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90) - - def test_cylindrical_joint(self): - cylindrical_base = ( - Solid.make_box(1, 1, 1) - .locate(Location((-0.5, -0.5, 0))) - .cut(Solid.make_cylinder(0.3, 1)) - ) - j1 = CylindricalJoint( - "base", - cylindrical_base, - Axis((0, 0, 1), (0, 0, -1)), - angle_reference=(1, 0, 0), - linear_range=(0, 1), - angular_range=(0, 90), - ) - dowel = Solid.make_cylinder(0.3, 1).cut( - Solid.make_box(1, 1, 1).locate(Location((-0.5, 0, 0))) - ) - j2 = RigidJoint("bottom", dowel, Location((0, 0, 0), (0, 0, 0))) - j1.connect_to(j2, 0.25, 90) - dowel_bbox = dowel.bounding_box() - self.assertVectorAlmostEquals(dowel_bbox.min, (0, -0.3, -0.25), 5) - self.assertVectorAlmostEquals(dowel_bbox.max, (0.3, 0.3, 0.75), 5) - - self.assertVectorAlmostEquals(j1.symbol.location.position, (0, 0, 1), 6) - self.assertVectorAlmostEquals( - j1.symbol.location.orientation, (-180, 0, -180), 6 - ) - self.assertEqual(len(j1.symbol.edges()), 2) - - # Test invalid position - with self.assertRaises(ValueError): - j1.connect_to(j2, position=5, angle=90) - - # Test invalid angle - with self.assertRaises(ValueError): - j1.connect_to(j2, position=0.5, angle=270) - - # Test invalid joint - with self.assertRaises(TypeError): - j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90) - - def test_cylindrical_joint_error_bad_angle_reference(self): - """Test that the angle_reference must be normal to the axis""" - with self.assertRaises(ValueError): - CylindricalJoint( - "base", - Solid.make_box(1, 1, 1), - Axis((0, 0, 1), (0, 0, -1)), - angle_reference=(1, 0, 1), - linear_range=(0, 1), - angular_range=(0, 90), - ) - - def test_cylindrical_joint_error_bad_position_and_angle(self): - """Test that the joint angle is within bounds""" + # import as face + stl_box = import_stl("test.stl") + self.assertVectorAlmostEquals(stl_box.position, (0, 0, 0), 5) - j1 = CylindricalJoint( - "base", - Solid.make_box(1, 1, 1), - Axis((0, 0, 1), (0, 0, -1)), - linear_range=(0, 1), - angular_range=(0, 90), - ) - j2 = RigidJoint("bottom", Solid.make_cylinder(1, 1), Location((0.5, 0.25, 0))) - with self.assertRaises(ValueError): - j1.connect_to(j2, position=0.5, angle=270) - with self.assertRaises(ValueError): - j1.connect_to(j2, position=4, angle=30) +class TestJupyter(DirectApiTestCase): + def test_repr_javascript(self): + shape = Solid.make_box(1, 1, 1) - def test_ball_joint(self): - socket_base = Solid.make_box(1, 1, 1).cut( - Solid.make_sphere(0.3, Plane((0.5, 0.5, 1))) - ) - j1 = BallJoint( - "socket", - socket_base, - Location((0.5, 0.5, 1)), - angular_range=((-45, 45), (-45, 45), (0, 360)), - ) - ball_rod = Solid.make_cylinder(0.15, 2).fuse( - Solid.make_sphere(0.3).locate(Location((0, 0, 2))) - ) - j2 = RigidJoint("ball", ball_rod, Location((0, 0, 2), (180, 0, 0))) - j1.connect_to(j2, (45, 45, 0)) - self.assertVectorAlmostEquals( - ball_rod.faces().filter_by(GeomType.PLANE)[0].center(CenterOf.GEOMETRY), - (1.914213562373095, -0.5, 2), - 5, - ) + # Test no exception on rendering to js + js1 = shape._repr_javascript_() - self.assertVectorAlmostEquals(j1.symbol.location.position, (0.5, 0.5, 1), 6) - self.assertVectorAlmostEquals(j1.symbol.location.orientation, (0, 0, 0), 6) + assert "function render" in js1 - with self.assertRaises(ValueError): - j1.connect_to(j2, (90, 45, 0)) - - # Test invalid joint - with self.assertRaises(TypeError): - j1.connect_to(Solid.make_box(1, 1, 1), (0, 0, 0)) + def test_display_error(self): + with self.assertRaises(AttributeError): + display(Vector()) class TestLocation(DirectApiTestCase): @@ -1406,6 +1413,22 @@ def test_to_axis(self): self.assertVectorAlmostEquals(axis.position, (1, 2, 3), 6) self.assertVectorAlmostEquals(axis.direction, (0, 1, 0), 6) + def test_eq(self): + loc = Location((1, 2, 3), (4, 5, 6)) + diff_posistion = Location((10, 20, 30), (4, 5, 6)) + diff_orientation = Location((1, 2, 3), (40, 50, 60)) + same = Location((1, 2, 3), (4, 5, 6)) + + self.assertEqual(loc, same) + self.assertNotEqual(loc, diff_posistion) + self.assertNotEqual(loc, diff_orientation) + + def test_neg(self): + loc = Location((1, 2, 3), (0, 35, 127)) + n_loc = -loc + self.assertVectorAlmostEquals(n_loc.position, (1, 2, 3), 5) + self.assertVectorAlmostEquals(n_loc.orientation, (180, -35, -127), 5) + class TestMatrix(DirectApiTestCase): def test_matrix_creation_and_access(self): @@ -1660,12 +1683,57 @@ def test_locations(self): self.assertVectorAlmostEquals(locs[3].position, (0, -1, 0), 5) self.assertVectorAlmostEquals(locs[3].orientation, (0, 90, 90), 5) - # def test_project(self): - # target = Face.make_rect(10, 10) - # source = Face.make_from_wires(Wire.make_circle(1, Plane((0, 0, 1)))) - # shadow = source.project(target, direction=(0, 0, -1)) - # self.assertVectorAlmostEquals(shadow.center(), (0, 0, 0), 5) - # self.assertAlmostEqual(shadow.area, math.pi, 5) + def test_project(self): + target = Face.make_rect(10, 10, Plane.XY.rotated((0, 45, 0))) + circle = Edge.make_circle(1).locate(Location((0, 0, 10))) + ellipse: Wire = circle.project(target, (0, 0, -1)) + bbox = ellipse.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-1, -1, -1), 5) + self.assertVectorAlmostEquals(bbox.max, (1, 1, 1), 5) + + def test_project2(self): + target = Cylinder(1, 10).faces().filter_by(GeomType.PLANE, reverse=True)[0] + square = Wire.make_rect(1, 1, normal=(1, 0, 0)).locate(Location((10, 0, 0))) + projections: list[Wire] = square.project( + target, direction=(-1, 0, 0), closest=False + ) + self.assertEqual(len(projections), 2) + + def test_is_forward(self): + plate = Box(10, 10, 1) - Cylinder(1, 1) + hole_edges = plate.edges().filter_by(GeomType.CIRCLE) + self.assertTrue(hole_edges.sort_by(Axis.Z)[-1].is_forward) + self.assertFalse(hole_edges.sort_by(Axis.Z)[0].is_forward) + + def test_offset_2d(self): + base_wire = Wire.make_polygon([(0, 0), (1, 0), (1, 1)], close=False) + corner = base_wire.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[-1] + base_wire = base_wire.fillet_2d(0.4, [corner]) + offset_wire = base_wire.offset_2d(0.1, side=Side.LEFT) + self.assertTrue(offset_wire.is_closed()) + self.assertEqual(len(offset_wire.edges().filter_by(GeomType.LINE)), 6) + self.assertEqual(len(offset_wire.edges().filter_by(GeomType.CIRCLE)), 2) + offset_wire_right = base_wire.offset_2d(0.1, side=Side.RIGHT) + self.assertAlmostEqual( + offset_wire_right.edges() + .filter_by(GeomType.CIRCLE) + .sort_by(SortBy.RADIUS)[-1] + .radius, + 0.5, + 4, + ) + + # Test for returned Edge - can't find a way to do this + # base_edge = Edge.make_circle(10, start_angle=40, end_angle=50) + # self.assertTrue(isinstance(offset_edge, Edge)) + # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) + # self.assertTrue(offset_edge.geom_type() == "CIRCLE") + # self.assertAlmostEqual(offset_edge.radius, 12, 5) + # base_edge = Edge.make_line((0, 1), (1, 10)) + # offset_edge = base_edge.offset_2d(2, side=Side.RIGHT, closed=False) + # self.assertTrue(isinstance(offset_edge, Edge)) + # self.assertTrue(offset_edge.geom_type() == "LINE") + # self.assertAlmostEqual(offset_edge.position_at(0).X, 3) class TestMixin3D(DirectApiTestCase): @@ -1676,11 +1744,11 @@ def test_chamfer(self): chamfer_box = box.chamfer(0.1, 0.2, box.edges().sort_by(Axis.Z)[-1:]) self.assertAlmostEqual(chamfer_box.volume, 1 - 0.01, 5) - def test_shell(self): - shell_box = Solid.make_box(1, 1, 1).shell([], thickness=-0.1) + def test_hollow(self): + shell_box = Solid.make_box(1, 1, 1).hollow([], thickness=-0.1) self.assertAlmostEqual(shell_box.volume, 1 - 0.8**3, 5) with self.assertRaises(ValueError): - Solid.make_box(1, 1, 1).shell([], thickness=0.1, kind=Kind.TANGENT) + Solid.make_box(1, 1, 1).hollow([], thickness=0.1, kind=Kind.TANGENT) def test_is_inside(self): self.assertTrue(Solid.make_box(1, 1, 1).is_inside((0.5, 0.5, 0.5))) @@ -1735,20 +1803,20 @@ class TestPlane(DirectApiTestCase): def test_class_properties(self): """Validate - Name xDir yDir zDir - =========== ======= ======= ====== - XY +x +y +z - YZ +y +z +x - ZX +z +x +y - XZ +x +z -y - YX +y +x -z - ZY +z +y -x - front +x +y +z - back -x +y -z - left +z +y -x - right -z +y +x - top +x -z +y - bottom +x +z -y + Name x_dir y_dir z_dir + ======= ====== ====== ====== + XY +x +y +z + YZ +y +z +x + ZX +z +x +y + XZ +x +z -y + YX +y +x -z + ZY +z +y -x + front +x +z -y + back -x +z +y + left -y +z -x + right +y +z +x + top +x +y +z + bottom +x -y -z """ planes = [ (Plane.XY, (1, 0, 0), (0, 0, 1)), @@ -1757,12 +1825,12 @@ def test_class_properties(self): (Plane.XZ, (1, 0, 0), (0, -1, 0)), (Plane.YX, (0, 1, 0), (0, 0, -1)), (Plane.ZY, (0, 0, 1), (-1, 0, 0)), - (Plane.front, (1, 0, 0), (0, 0, 1)), - (Plane.back, (-1, 0, 0), (0, 0, -1)), - (Plane.left, (0, 0, 1), (-1, 0, 0)), - (Plane.right, (0, 0, -1), (1, 0, 0)), - (Plane.top, (1, 0, 0), (0, 1, 0)), - (Plane.bottom, (1, 0, 0), (0, -1, 0)), + (Plane.front, (1, 0, 0), (0, -1, 0)), + (Plane.back, (-1, 0, 0), (0, 1, 0)), + (Plane.left, (0, -1, 0), (-1, 0, 0)), + (Plane.right, (0, 1, 0), (1, 0, 0)), + (Plane.top, (1, 0, 0), (0, 0, 1)), + (Plane.bottom, (1, 0, 0), (0, 0, -1)), ] for plane, x_dir, z_dir in planes: self.assertVectorAlmostEquals(plane.x_dir, x_dir, 5) @@ -1809,10 +1877,8 @@ def test_plane_init(self): p.y_dir, (-math.sqrt(2) / 2, math.sqrt(2) / 2, 0), 6 ) self.assertVectorAlmostEquals(p.z_dir, (0, 0, 1), 6) - self.assertVectorAlmostEquals(loc.position, p.to_location().position, 6) - self.assertVectorAlmostEquals( - loc.orientation, p.to_location().orientation, 6 - ) + self.assertVectorAlmostEquals(loc.position, p.location.position, 6) + self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6) # rotated location around x and origin <> (0,0,0) loc = Location((0, 2, -1), (45, 0, 0)) @@ -1825,8 +1891,8 @@ def test_plane_init(self): self.assertVectorAlmostEquals( p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6 ) - self.assertVectorAlmostEquals(loc.position, p.to_location().position, 6) - self.assertVectorAlmostEquals(loc.orientation, p.to_location().orientation, 6) + self.assertVectorAlmostEquals(loc.position, p.location.position, 6) + self.assertVectorAlmostEquals(loc.orientation, p.location.orientation, 6) # from a face f = Face.make_rect(1, 2).located(Location((1, 2, 3), (45, 0, 45))) @@ -1841,11 +1907,9 @@ def test_plane_init(self): self.assertVectorAlmostEquals( p.z_dir, (0, -math.sqrt(2) / 2, math.sqrt(2) / 2), 6 ) + self.assertVectorAlmostEquals(f.location.position, p.location.position, 6) self.assertVectorAlmostEquals( - f.location.position, p.to_location().position, 6 - ) - self.assertVectorAlmostEquals( - f.location.orientation, p.to_location().orientation, 6 + f.location.orientation, p.location.orientation, 6 ) # from a face with x_dir @@ -1936,16 +2000,81 @@ def test_plane_methods(self): for i, target_point in enumerate(target_vertices): self.assertTupleAlmostEquals(target_point, local_box_vertices[i], 7) + def test_localize_vertex(self): + vertex = Vertex(random.random(), random.random(), random.random()) + self.assertTupleAlmostEquals( + Plane.YZ.to_local_coords(vertex).to_tuple(), + Plane.YZ.to_local_coords(vertex.to_vector()).to_tuple(), + 5, + ) + def test_repr(self): self.assertEqual( repr(Plane.XY), "Plane(o=(0.00, 0.00, 0.00), x=(1.00, 0.00, 0.00), z=(0.00, 0.00, 1.00))", ) - def test_set_origin(self): - offset_plane = Plane.XY - offset_plane.set_origin2d(1, 1) - self.assertVectorAlmostEquals(offset_plane.origin, (1, 1, 0), 5) + def test_shift_origin_axis(self): + cyl = Cylinder(1, 2, align=Align.MIN) + top = cyl.faces().sort_by(Axis.Z)[-1] + pln = Plane(top).shift_origin(Axis.Z) + with BuildPart() as p: + add(cyl) + with BuildSketch(pln): + with Locations((1, 1)): + Circle(0.5) + extrude(amount=-2, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, math.pi * (1**2 - 0.5**2) * 2, 5) + + def test_shift_origin_vertex(self): + box = Box(1, 1, 1, align=Align.MIN) + front = box.faces().sort_by(Axis.X)[-1] + pln = Plane(front).shift_origin( + front.vertices().group_by(Axis.Z)[-1].sort_by(Axis.Y)[-1] + ) + with BuildPart() as p: + add(box) + with BuildSketch(pln): + with Locations((-0.5, 0.5)): + Circle(0.5) + extrude(amount=-1, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, 1**3 - math.pi * (0.5**2) * 1, 5) + + def test_shift_origin_vector(self): + with BuildPart() as p: + Box(4, 4, 2) + b = fillet(p.edges().filter_by(Axis.Z), 0.5) + top = p.faces().sort_by(Axis.Z)[-1] + ref = ( + top.edges() + .filter_by(GeomType.CIRCLE) + .group_by(Axis.X)[-1] + .sort_by(Axis.Y)[0] + .arc_center + ) + pln = Plane(top, x_dir=(0, 1, 0)).shift_origin(ref) + with BuildSketch(pln): + with Locations((0.5, 0.5)): + Rectangle(2, 2, align=Align.MIN) + extrude(amount=-1, mode=Mode.SUBTRACT) + self.assertAlmostEqual(p.part.volume, b.volume - 2**2 * 1, 5) + + def test_shift_origin_error(self): + with self.assertRaises(ValueError): + Plane.XY.shift_origin(Vertex(1, 1, 1)) + + with self.assertRaises(ValueError): + Plane.XY.shift_origin((1, 1, 1)) + + with self.assertRaises(ValueError): + Plane.XY.shift_origin(Axis((0, 0, 1), (0, 1, 0))) + + with self.assertRaises(TypeError): + Plane.XY.shift_origin(Edge.make_line((0, 0), (1, 1))) + + def test_move(self): + pln = Plane.XY.move(Location((1, 2, 3))) + self.assertVectorAlmostEquals(pln.origin, (1, 2, 3), 5) def test_rotated(self): rotated_plane = Plane.XY.rotated((45, 0, 0)) @@ -2006,10 +2135,26 @@ def test_plane_not_equal(self): ) def test_to_location(self): - loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).to_location() + loc = Plane(origin=(1, 2, 3), x_dir=(0, 1, 0), z_dir=(0, 0, 1)).location self.assertVectorAlmostEquals(loc.position, (1, 2, 3), 5) self.assertVectorAlmostEquals(loc.orientation, (0, 0, 90), 5) + def test_find_intersection(self): + self.assertVectorAlmostEquals( + Plane.XY.find_intersection(Axis((1, 2, 3), (0, 0, -1))), (1, 2, 0), 5 + ) + self.assertIsNone(Plane.XY.find_intersection(Axis((1, 2, 3), (0, 1, 0)))) + + def test_from_non_planar_face(self): + flat = Face.make_rect(1, 1) + pln = Plane(flat) + self.assertTrue(isinstance(pln, Plane)) + cyl = ( + Solid.make_cylinder(1, 4).faces().filter_by(GeomType.PLANE, reverse=True)[0] + ) + with self.assertRaises(ValueError): + pln = Plane(cyl) + class TestProjection(DirectApiTestCase): def test_flat_projection(self): @@ -2026,21 +2171,16 @@ def test_flat_projection(self): ] self.assertEqual(len(projected_text_faces), 4) - # def test_conical_projection(self): - # sphere = Solid.make_sphere(50) - # projection_center = Vector(0, 0, 0) - # planar_text_faces = ( - # Compound.make_text("Conical", 25, halign=Halign.CENTER) - # .rotate(Axis.X, 90) - # .translate((0, -60, 0)) - # .faces() - # ) - - # projected_text_faces = [ - # f.project_to_shape(sphere, center=projection_center)[0] - # for f in planar_text_faces - # ] - # self.assertEqual(len(projected_text_faces), 8) + def test_multiple_output_wires(self): + target = Box(10, 10, 4) - Pos((0, 0, 2)) * Box(5, 5, 2) + circle = Wire.make_circle(3, Plane.XY.offset(10)) + projection = circle.project_to_shape(target, (0, 0, -1)) + bbox = projection[0].bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-3, -3, 1), 2) + self.assertVectorAlmostEquals(bbox.max, (3, 3, 2), 2) + bbox = projection[1].bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-3, -3, -2), 2) + self.assertVectorAlmostEquals(bbox.max, (3, 3, -2), 2) def test_text_projection(self): sphere = Solid.make_sphere(50) @@ -2061,14 +2201,11 @@ def test_text_projection(self): self.assertEqual(len(projected_text.solids()), 0) self.assertEqual(len(projected_text.faces()), 3) - # def test_error_handling(self): - # sphere = Solid.make_sphere(50) - # f = Face.make_rect(10, 10) - # with self.assertRaises(ValueError): - # f.project_to_shape(sphere, center=None, direction=None)[0] - # w = Face.make_rect(10, 10).outer_wire() - # with self.assertRaises(ValueError): - # w.project_to_shape(sphere, center=None, direction=None)[0] + def test_error_handling(self): + sphere = Solid.make_sphere(50) + circle = Wire.make_circle(1) + with self.assertRaises(ValueError): + circle.project_to_shape(sphere, center=None, direction=None)[0] def test_project_edge(self): projection = Edge.make_circle(1, Plane.XY.offset(-5)).project_to_shape( @@ -2141,10 +2278,15 @@ def test_faces_intersected_by_axis(self): self.assertTrue(box.faces().sort_by(sort_by=Axis.Z)[-1] in intersected_faces) def test_split(self): - box = Solid.make_box(1, 1, 1, Plane((-0.5, 0, 0))) - # halves = box.split(Face.make_rect(2, 2, normal=(1, 0, 0))) - halves = box.split(Face.make_rect(2, 2, Plane.YZ)) - self.assertEqual(len(halves.solids()), 2) + shape = Box(1, 1, 1) - Pos((0, 0, -0.25)) * Box(1, 0.5, 0.5) + split_shape = shape.split(Plane.XY, keep=Keep.BOTTOM) + self.assertEqual(len(split_shape.solids()), 2) + self.assertAlmostEqual(split_shape.volume, 0.25, 5) + self.assertTrue(isinstance(split_shape, Compound)) + split_shape = shape.split(Plane.XY, keep=Keep.TOP) + self.assertEqual(len(split_shape.solids()), 1) + self.assertTrue(isinstance(split_shape, Solid)) + self.assertAlmostEqual(split_shape.volume, 0.5, 5) def test_distance(self): sphere1 = Solid.make_sphere(1, Plane((-5, 0, 0))) @@ -2172,8 +2314,9 @@ def test_max_fillet(self): ) with self.assertRaises(ValueError): box = Solid.make_box(1, 1, 1) - invalid_object = box.fillet(0.75, box.edges()) - invalid_object.max_fillet(invalid_object.edges()) + box.fillet(0.75, box.edges()) + # invalid_object = box.fillet(0.75, box.edges()) + # invalid_object.max_fillet(invalid_object.edges()) def test_locate_bb(self): bounding_box = Solid.make_cone(1, 2, 1).bounding_box() @@ -2195,18 +2338,6 @@ def test_tessellate(self): self.assertEqual(len(verts), 24) self.assertEqual(len(triangles), 12) - # def test_to_vtk_poly_data(self): - - # from vtkmodules.vtkCommonDataModel import vtkPolyData - - # f = Face.make_rect(2, 2) - # vtk = f.to_vtk_poly_data(normals=False) - # self.assertTrue(isinstance(vtk, vtkPolyData)) - # self.assertEqual(vtk.GetNumberOfPolys(), 2) - - # def test_repr_javascript_(self): - # print(Shape._repr_javascript_(Face)) - def test_transformed(self): """Validate that transformed works the same as changing location""" rotation = (uniform(0, 360), uniform(0, 360), uniform(0, 360)) @@ -2264,6 +2395,89 @@ def test_clean_error(self): positive_half, negative_half = [s.clean() for s in sphere.cut(divider).solids()] self.assertGreater(abs(positive_half.volume - negative_half.volume), 0, 1) + def test_relocate(self): + box = Solid.make_box(10, 10, 10).move(Location((20, -5, -5))) + cylinder = Solid.make_cylinder(2, 50).move(Location((0, 0, 0), (0, 90, 0))) + + box_with_hole = box.cut(cylinder) + box_with_hole.relocate(box.location) + + self.assertEqual(box.location, box_with_hole.location) + + bbox1 = box.bounding_box() + bbox2 = box_with_hole.bounding_box() + self.assertVectorAlmostEquals(bbox1.min, bbox2.min, 5) + self.assertVectorAlmostEquals(bbox1.max, bbox2.max, 5) + + def test_project_to_viewport(self): + # Basic test + box = Solid.make_box(10, 10, 10) + visible, hidden = box.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 9) + self.assertEqual(len(hidden), 3) + + # Contour edges + cyl = Solid.make_cylinder(2, 10) + visible, hidden = cyl.project_to_viewport((-20, 20, 20)) + # Note that some edges are broken into two + self.assertEqual(len(visible), 6) + self.assertEqual(len(hidden), 2) + + # Hidden coutour edges + hole = box - cyl + visible, hidden = hole.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 13) + self.assertEqual(len(hidden), 6) + + # Outline edges + sphere = Solid.make_sphere(5) + visible, hidden = sphere.project_to_viewport((-20, 20, 20)) + self.assertEqual(len(visible), 1) + self.assertEqual(len(hidden), 0) + + def test_vertex(self): + v = Edge.make_circle(1).vertex() + self.assertTrue(isinstance(v, Vertex)) + with self.assertWarns(UserWarning): + Wire.make_rect(1, 1).vertex() + + def test_edge(self): + e = Edge.make_circle(1).edge() + self.assertTrue(isinstance(e, Edge)) + with self.assertWarns(UserWarning): + Wire.make_rect(1, 1).edge() + + def test_wire(self): + w = Wire.make_circle(1).wire() + self.assertTrue(isinstance(w, Wire)) + with self.assertWarns(UserWarning): + Solid.make_box(1, 1, 1).wire() + + def test_compound(self): + c = Compound.make_text("hello", 10) + self.assertTrue(isinstance(c, Compound)) + c2 = Compound.make_text("world", 10) + with self.assertWarns(UserWarning): + Compound(children=[c, c2]).compound() + + def test_face(self): + f = Face.make_rect(1, 1) + self.assertTrue(isinstance(f, Face)) + with self.assertWarns(UserWarning): + Solid.make_box(1, 1, 1).face() + + def test_shell(self): + s = Solid.make_sphere(1).shell() + self.assertTrue(isinstance(s, Shell)) + with self.assertWarns(UserWarning): + extrude(Compound.make_text("two", 10), amount=5).shell() + + def test_solid(self): + s = Solid.make_sphere(1).solid() + self.assertTrue(isinstance(s, Solid)) + with self.assertWarns(UserWarning): + Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH).solid() + class TestShapeList(DirectApiTestCase): """Test ShapeList functionality""" @@ -2272,7 +2486,7 @@ def test_sort_by(self): faces = Solid.make_box(1, 2, 3).faces() < SortBy.AREA self.assertAlmostEqual(faces[-1].area, 2, 5) - def test_filter_by(self): + def test_filter_by_geomtype(self): non_planar_faces = ( Solid.make_cylinder(1, 1).faces().filter_by(GeomType.PLANE, reverse=True) ) @@ -2282,6 +2496,22 @@ def test_filter_by(self): with self.assertRaises(ValueError): Solid.make_box(1, 1, 1).faces().filter_by("True") + def test_filter_by_axis(self): + box = Solid.make_box(1, 1, 1) + self.assertEqual(len(box.faces().filter_by(Axis.X)), 2) + self.assertEqual(len(box.edges().filter_by(Axis.X)), 4) + self.assertEqual(len(box.vertices().filter_by(Axis.X)), 0) + + def test_filter_by_callable_predicate(self): + boxes = [Solid.make_box(1, 1, 1) for _ in range(3)] + boxes[0].label = "A" + boxes[1].label = "A" + boxes[2].label = "B" + shapelist = ShapeList(boxes) + + self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "A")), 2) + self.assertEqual(len(shapelist.filter_by(lambda s: s.label == "B")), 1) + def test_first_last(self): vertices = ( Solid.make_box(1, 1, 1).vertices().sort_by(Axis((0, 0, 0), (1, 1, 1))) @@ -2325,6 +2555,43 @@ def test_group_by(self): with self.assertRaises(ValueError): boxes.solids().group_by("AREA") + def test_group_by_callable_predicate(self): + boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] + boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] + for box in boxesA: + box.label = "A" + for box in boxesB: + box.label = "B" + boxNoLabel = Solid.make_box(1, 1, 1) + + shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) + result = shapelist.group_by(lambda shape: shape.label) + + self.assertEqual([len(group) for group in result], [1, 3, 2]) + + def test_group_by_retrieve_groups(self): + boxesA = [Solid.make_box(1, 1, 1) for _ in range(3)] + boxesB = [Solid.make_box(1, 1, 1) for _ in range(2)] + for box in boxesA: + box.label = "A" + for box in boxesB: + box.label = "B" + boxNoLabel = Solid.make_box(1, 1, 1) + + shapelist = ShapeList(boxesA + boxesB + [boxNoLabel]) + result = shapelist.group_by(lambda shape: shape.label) + + self.assertEqual(len(result.group("")), 1) + self.assertEqual(len(result.group("A")), 3) + self.assertEqual(len(result.group("B")), 2) + self.assertEqual(result.group(""), result[0]) + self.assertEqual(result.group("A"), result[1]) + self.assertEqual(result.group("B"), result[2]) + self.assertEqual(result.group_for(boxesA[0]), result.group_for(boxesA[0])) + self.assertNotEqual(result.group_for(boxesA[0]), result.group_for(boxesB[0])) + with self.assertRaises(KeyError): + result.group("C") + def test_distance(self): with BuildPart() as box: Box(1, 2, 3) @@ -2351,7 +2618,7 @@ def test_distance_equal(self): self.assertEqual(len(box.edges().sort_by_distance((0, 0, 0))), 12) -class TestShell(DirectApiTestCase): +class TestShells(DirectApiTestCase): def test_shell_init(self): box_faces = Solid.make_box(1, 1, 1).faces() box_shell = Shell.make_shell(box_faces) @@ -2363,7 +2630,7 @@ def test_center(self): self.assertVectorAlmostEquals(box_shell.center(), (0.5, 0.5, 0.5), 5) -class TestSolid(unittest.TestCase): +class TestSolid(DirectApiTestCase): def test_make_solid(self): box_faces = Solid.make_box(1, 1, 1).faces() box_shell = Shell.make_shell(box_faces) @@ -2372,13 +2639,94 @@ def test_make_solid(self): self.assertAlmostEqual(box.volume, 1, 5) self.assertTrue(box.is_valid()) - def test_extrude_with_taper(self): - base = Face.make_rect(1, 1) - pyramid = Solid.extrude_linear(base, normal=(0, 0, 1), taper=1) - self.assertLess( - pyramid.faces().sort_by(Axis.Z)[-1].area, - pyramid.faces().sort_by(Axis.Z)[0].area, + def test_extrude(self): + v = Edge.extrude(Vertex(1, 1, 1), (0, 0, 1)) + self.assertAlmostEqual(v.length, 1, 5) + + e = Face.extrude(Edge.make_line((2, 1), (2, 0)), (0, 0, 1)) + self.assertAlmostEqual(e.area, 1, 5) + + w = Shell.extrude( + Wire.make_wire( + [Edge.make_line((1, 1), (0, 2)), Edge.make_line((1, 1), (1, 0))] + ), + (0, 0, 1), + ) + self.assertAlmostEqual(w.area, 1 + math.sqrt(2), 5) + + f = Solid.extrude(Face.make_rect(1, 1), (0, 0, 1)) + self.assertAlmostEqual(f.volume, 1, 5) + + s = Compound.extrude( + Shell.make_shell( + Solid.make_box(1, 1, 1) + .locate(Location((-2, 1, 0))) + .faces() + .sort_by(Axis((0, 0, 0), (1, 1, 1)))[-2:] + ), + (0.1, 0.1, 0.1), ) + self.assertAlmostEqual(s.volume, 0.2, 5) + + with self.assertRaises(ValueError): + Solid.extrude(Solid.make_box(1, 1, 1), (0, 0, 1)) + + def test_extrude_taper(self): + a = 1 + rect = Face.make_rect(a, a) + flipped = -rect + for direction in [Vector(0, 0, 2), Vector(0, 0, -2)]: + for taper in [10, -10]: + offset_amt = -direction.length * math.tan(math.radians(taper)) + for face in [rect, flipped]: + with self.subTest( + f"{direction=}, {taper=}, flipped={face==flipped}" + ): + taper_solid = Solid.extrude_taper(face, direction, taper) + # V = 1/3 × h × (a² + b² + ab) + h = Vector(direction).length + b = a + 2 * offset_amt + v = h * (a**2 + b**2 + a * b) / 3 + self.assertAlmostEqual(taper_solid.volume, v, 5) + bbox = taper_solid.bounding_box() + size = max(1, b) / 2 + if direction.Z > 0: + self.assertVectorAlmostEquals( + bbox.min, (-size, -size, 0), 1 + ) + self.assertVectorAlmostEquals(bbox.max, (size, size, h), 1) + else: + self.assertVectorAlmostEquals( + bbox.min, (-size, -size, -h), 1 + ) + self.assertVectorAlmostEquals(bbox.max, (size, size, 0), 1) + + def test_extrude_taper_with_hole(self): + rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) + direction = Vector(0, 0, 0.5) + taper = 10 + taper_solid = Solid.extrude_taper(rect_hole, direction, taper) + offset_amt = -direction.length * math.tan(math.radians(taper)) + hole = taper_solid.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + self.assertAlmostEqual(hole.radius, 0.25 - offset_amt, 5) + + def test_extrude_taper_with_hole_flipped(self): + rect_hole = Face.make_rect(1, 1).make_holes([Wire.make_circle(0.25)]) + direction = Vector(0, 0, 1) + taper = 10 + taper_solid_t = Solid.extrude_taper(rect_hole, direction, taper, True) + taper_solid_f = Solid.extrude_taper(rect_hole, direction, taper, False) + hole_t = taper_solid_t.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + hole_f = taper_solid_f.edges().filter_by(GeomType.CIRCLE).sort_by(Axis.Z)[-1] + self.assertGreater(hole_t.radius, hole_f.radius) + + def test_extrude_taper_oblique(self): + rect = Face.make_rect(2, 1) + rect_hole = rect.make_holes([Wire.make_circle(0.25)]) + o_rect_hole = rect_hole.moved(Location((0, 0, 0), (1, 0.1, 0), 77)) + taper0 = Solid.extrude_taper(rect_hole, (0, 0, 1), 5) + taper1 = Solid.extrude_taper(o_rect_hole, o_rect_hole.normal_at(), 5) + self.assertAlmostEqual(taper0.volume, taper1.volume, 5) def test_extrude_linear_with_rotation(self): # Face @@ -2415,54 +2763,18 @@ def test_extrude_until(self): extrusion = Solid.extrude_until(square, box, (0, 0, 1), Until.LAST) self.assertAlmostEqual(extrusion.volume, 4, 5) + def test_sweep(self): + path = Edge.make_spline([(0, 0), (3, 5), (7, -2)]) + section = Wire.make_circle(1, Plane(path @ 0, z_dir=path % 0)) + area = Face.make_from_wires(section).area + swept = Solid.sweep(section, path) + self.assertAlmostEqual(swept.volume, path.length * area, 0) -class TestSVG(unittest.TestCase): - def test_svg_export_import(self): - with BuildSketch() as square: - Rectangle(1, 1) - square.sketch.export_svg( - "test_svg.svg", (10, -10, 10), (0, 0, 1), svg_opts={"show_axes": False} - ) - svg_imported = import_svg("test_svg.svg") - self.assertEqual(len(svg_imported), 4) - - with BuildSketch() as square: - Circle(1) - square.sketch.export_svg( - "test_svg.svg", (0, 0, 10), (0, 1, 0), svg_opts={"show_axes": True} - ) - svg_imported = import_svg("test_svg.svg") - self.assertGreater(len(svg_imported), 1) - - box = Solid.make_box(1, 1, 1) - box.export_svg( - "test_svg.svg", - (10, -10, 10), - (0, 0, 1), - svg_opts={"show_axes": False, "pixel_scale": 100, "stroke_width": 1}, - ) - svg_imported = import_svg("test_svg.svg") - self.assertEqual(len(svg_imported), 16) - - box = Solid.make_box(1, 1, 1) - box.export_svg( - "test_svg.svg", - (10, -10, 10), - (0, 0, 1), - svg_opts={ - "show_axes": False, - "pixel_scale": 100, - "stroke_width": 1, - "show_hidden": False, - }, - ) - svg_imported = import_svg("test_svg.svg") - self.assertEqual(len(svg_imported), 9) - - os.remove("test_svg.svg") - - with self.assertRaises(ValueError): - import_svg("test_svg.svg") + def test_hollow_sweep(self): + path = Edge.make_line((0, 0, 0), (0, 0, 5)) + section = (Rectangle(1, 1) - Rectangle(0.1, 0.1)).faces()[0] + swept = Solid.sweep(section, path) + self.assertAlmostEqual(swept.volume, 5 * (1 - 0.1**2), 5) class TestVector(DirectApiTestCase): @@ -2632,7 +2944,7 @@ def test_copy(self): self.assertVectorAlmostEquals(v3, (1, 2, 3), 7) -class VertexTests(DirectApiTestCase): +class TestVertex(DirectApiTestCase): """Test the extensions to the cadquery Vertex class""" def test_basic_vertex(self): @@ -2687,8 +2999,16 @@ def test_vertex_to_vector(self): self.assertIsInstance(Vertex(0, 0, 0).to_vector(), Vector) self.assertVectorAlmostEquals(Vertex(0, 0, 0).to_vector(), (0.0, 0.0, 0.0), 7) + def test_vertex_init_error(self): + with self.assertRaises(ValueError): + Vertex(0.0, 1.0) + + def test_no_intersect(self): + with self.assertRaises(NotImplementedError): + Vertex(1, 2, 3) & Vertex(5, 6, 7) -class TestWire(unittest.TestCase): + +class TestWire(DirectApiTestCase): def test_ellipse_arc(self): full_ellipse = Wire.make_ellipse(2, 1) half_ellipse = Wire.make_ellipse( @@ -2741,6 +3061,60 @@ def test_make_convex_hull(self): hull_wire = Wire.make_convex_hull(adjoining_edges) self.assertAlmostEqual(Face.make_from_wires(hull_wire).area, 319.9612, 4) + # def test_fix_degenerate_edges(self): + # # Can't find a way to create one + # edge0 = Edge.make_line((0, 0, 0), (1, 0, 0)) + # edge1 = Edge.make_line(edge0 @ 0, edge0 @ 0 + Vector(0, 1, 0)) + # edge1a = edge1.trim(0, 1e-7) + # edge1b = edge1.trim(1e-7, 1.0) + # edge2 = Edge.make_line(edge1 @ 1, edge1 @ 1 + Vector(1, 1, 0)) + # wire = Wire.make_wire([edge0, edge1a, edge1b, edge2]) + # fixed_wire = wire.fix_degenerate_edges(1e-6) + # self.assertEqual(len(fixed_wire.edges()), 2) + + def test_trim(self): + e0 = Edge.make_line((0, 0), (1, 0)) + e1 = Edge.make_line((2, 0), (1, 0)) + e2 = Edge.make_line((2, 0), (3, 0)) + w1 = Wire.make_wire([e0, e1, e2]) + t1 = w1.trim(0.2, 0.9).move(Location((0, 0.1, 0))) + self.assertAlmostEqual(t1.length, 2.1, 5) + + e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) + # Three edges are created 0->0.5->0.75->1.0 + o = e.offset_2d(10, side=Side.RIGHT, closed=False) + t2 = o.trim(0.1, 0.9) + self.assertAlmostEqual(t2.length, o.length * 0.8, 5) + + t3 = o.trim(0.5, 1.0) + self.assertAlmostEqual(t3.length, o.length * 0.5, 5) + + t4 = o.trim(0.5, 0.75) + self.assertAlmostEqual(t4.length, o.length * 0.25, 5) + + with self.assertRaises(ValueError): + o.trim(0.75, 0.25) + + def test_param_at_point(self): + e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20)) + # Three edges are created 0->0.5->0.75->1.0 + o = e.offset_2d(10, side=Side.RIGHT, closed=False) + + e0 = Edge.make_line((0, 0), (1, 0)) + e1 = Edge.make_line((2, 0), (1, 0)) + e2 = Edge.make_line((2, 0), (3, 0)) + w1 = Wire.make_wire([e0, e1, e2]) + for wire in [o, w1]: + u_value = random.random() + position = wire.position_at(u_value) + self.assertAlmostEqual(wire.param_at_point(position), u_value, 4) + + with self.assertRaises(ValueError): + o.param_at_point((-1, 1)) + + with self.assertRaises(ValueError): + w1.param_at_point((20, 20, 20)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_drafting.py b/tests/test_drafting.py new file mode 100644 index 00000000..6048d062 --- /dev/null +++ b/tests/test_drafting.py @@ -0,0 +1,309 @@ +""" +drafting unittests + +name: test_drafting.py +by: Gumyr +date: September 17th, 2023 + +desc: + This python module contains the unittests for the drafting functionality. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +import math +import unittest +from datetime import date + +from build123d import ( + IN, + Axis, + BuildLine, + BuildSketch, + Color, + Edge, + Face, + FontStyle, + GeomType, + HeadType, + Mode, + NumberDisplay, + Polyline, + RadiusArc, + Rectangle, + Sketch, + Unit, + add, + make_face, + offset, +) +from build123d.drafting import ( + ArrowHead, + DimensionLine, + Draft, + ExtensionLine, + TechnicalDrawing, +) + +metric = Draft( + font_size=3.0, + font="Arial", + font_style=FontStyle.REGULAR, + head_type=HeadType.CURVED, + arrow_length=3.0, + line_width=0.25, + unit=Unit.MM, + number_display=NumberDisplay.DECIMAL, + display_units=True, + decimal_precision=2, + fractional_precision=64, + extension_gap=2.0, +) +imperial = Draft( + font_size=5.0, + font="Arial", + font_style=FontStyle.REGULAR, + head_type=HeadType.CURVED, + arrow_length=3.0, + line_width=0.25, + unit=Unit.IN, + number_display=NumberDisplay.FRACTION, + display_units=True, + decimal_precision=2, + fractional_precision=64, + extension_gap=2.0, +) + + +def create_test_sketch() -> tuple[Sketch, Sketch, Sketch]: + with BuildSketch() as sketchy: + with BuildLine(): + l1 = Polyline((10, 20), (-20, 20), (-20, -20), (10, -20)) + RadiusArc(l1 @ 0, l1 @ 1, 25) + make_face() + outside = sketchy.sketch + inside = offset(amount=-0.5, mode=Mode.SUBTRACT) + return (sketchy.sketch, outside, inside) + + +class TestClassInstantiation(unittest.TestCase): + + """Test Draft class instantiation""" + + def test_draft_instantiation(self): + """Parameter parsing""" + with self.assertRaises(ValueError): + Draft(fractional_precision=37) + + +class TestDraftFunctionality(unittest.TestCase): + """Test core drafting functionality""" + + def test_number_with_units(self): + metric_drawing = Draft(decimal_precision=2) + self.assertEqual(metric_drawing._number_with_units(3.141), "3.14mm") + self.assertEqual(metric_drawing._number_with_units(3.149), "3.15mm") + self.assertEqual(metric_drawing._number_with_units(0), "0.00mm") + self.assertEqual( + metric_drawing._number_with_units(3.14, tolerance=0.01), "3.14 ±0.01mm" + ) + self.assertEqual( + metric_drawing._number_with_units(3.14, tolerance=(0.01, 0)), + "3.14 +0.01 -0.00mm", + ) + whole_number_drawing = Draft(decimal_precision=-1) + self.assertEqual(whole_number_drawing._number_with_units(314.1), "310mm") + + imperial_drawing = Draft(unit=Unit.IN) + self.assertEqual(imperial_drawing._number_with_units((5 / 8) * IN), '0.62"') + imperial_fractional_drawing = Draft( + unit=Unit.IN, number_display=NumberDisplay.FRACTION, fractional_precision=64 + ) + self.assertEqual( + imperial_fractional_drawing._number_with_units((5 / 8) * IN), '5/8"' + ) + self.assertEqual( + imperial_fractional_drawing._number_with_units(math.pi * IN), '3 9/64"' + ) + imperial_fractional_drawing.fractional_precision = 16 + self.assertEqual( + imperial_fractional_drawing._number_with_units(math.pi * IN), '3 1/8"' + ) + + def test_label_to_str(self): + metric_drawing = Draft(decimal_precision=0) + line = Edge.make_line((0, 0, 0), (100, 0, 0)) + with self.assertRaises(ValueError): + metric_drawing._label_to_str( + label=None, + line_wire=line, + label_angle=True, + tolerance=0, + ) + arc1 = Edge.make_circle(100, start_angle=0, end_angle=30) + angle_str = metric_drawing._label_to_str( + label=None, + line_wire=arc1, + label_angle=True, + tolerance=0, + ) + self.assertEqual(angle_str, "30°") + + +class ArrowHeadTests(unittest.TestCase): + def test_arrowhead_types(self): + arrow = ArrowHead(10, HeadType.CURVED) + bbox = arrow.bounding_box() + self.assertEqual(len(arrow.edges().filter_by(GeomType.CIRCLE)), 2) + self.assertAlmostEqual(bbox.size.X, 10, 5) + + arrow = ArrowHead(10, HeadType.FILLETED) + bbox = arrow.bounding_box() + self.assertEqual(len(arrow.edges().filter_by(GeomType.CIRCLE)), 5) + self.assertLess(bbox.size.X, 10) + + arrow = ArrowHead(10, HeadType.STRAIGHT) + self.assertEqual(len(arrow.edges().filter_by(GeomType.CIRCLE)), 0) + bbox = arrow.bounding_box() + self.assertAlmostEqual(bbox.size.X, 10, 5) + + +class DimensionLineTestCase(unittest.TestCase): + def test_two_points(self): + d_line = DimensionLine([(0, 0, 0), (100, 0, 0)], draft=metric) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.max.X, 100, 5) + self.assertAlmostEqual(d_line.dimension, 100, 5) + self.assertEqual(len(d_line.faces()), 10) + + def test_three_points(self): + with self.assertRaises(ValueError): + DimensionLine([(0, 0, 0), (50, 0, 0), (50, 50, 0)], draft=metric) + + def test_edge(self): + d_line = DimensionLine(Edge.make_line((0, 0), (100, 0)), draft=metric) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.max.X, 100, 5) + self.assertEqual(len(d_line.faces()), 10) + + def test_vertices(self): + d_line = DimensionLine( + Edge.make_line((0, 0), (100, 0)).vertices(), draft=metric + ) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.max.X, 100, 5) + self.assertEqual(len(d_line.faces()), 10) + + def test_label(self): + d_line = DimensionLine([(0, 0, 0), (100, 0, 0)], label="Test", draft=metric) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.max.X, 100, 5) + self.assertEqual(len(d_line.faces()), 6) + + def test_face(self): + with self.assertRaises(ValueError): + DimensionLine(Face.make_rect(100, 100), draft=metric) + + def test_builder_mode(self): + with BuildSketch() as s1: + Rectangle(100, 100) + hole = offset(amount=-5, mode=Mode.SUBTRACT) + d_line = DimensionLine( + [ + hole.vertices().group_by(Axis.Y)[-1].sort_by(Axis.X)[-1], + hole.vertices().group_by(Axis.Y)[0].sort_by(Axis.X)[0], + ], + draft=metric, + ) + self.assertGreater(hole.intersect(d_line).area, 0) + + def test_outside_arrows(self): + d_line = DimensionLine([(0, 0, 0), (15, 0, 0)], draft=metric) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.size.X, 15 + 4 * metric.arrow_length, 5) + self.assertAlmostEqual(d_line.dimension, 15, 5) + self.assertEqual(len(d_line.faces()), 9) + + def test_outside_label(self): + d_line = DimensionLine([(0, 0, 0), (5, 0, 0)], draft=metric) + bbox = d_line.bounding_box() + self.assertGreater(bbox.size.X, 5 + 4 * metric.arrow_length) + self.assertAlmostEqual(d_line.dimension, 5, 5) + self.assertEqual(len(d_line.faces()), 8) + + def test_single_outside_label(self): + d_line = DimensionLine( + [(0, 0, 0), (5, 0, 0)], draft=metric, arrows=(False, True) + ) + bbox = d_line.bounding_box() + self.assertAlmostEqual(bbox.min.X, 5, 5) + self.assertAlmostEqual(d_line.dimension, 5, 5) + self.assertEqual(len(d_line.faces()), 7) + + def test_no_arrows(self): + with self.assertRaises(ValueError): + DimensionLine([(0, 0, 0), (5, 0, 0)], draft=metric, arrows=(False, False)) + + +class ExtensionLineTestCase(unittest.TestCase): + def test_min_x(self): + shape, outer, inner = create_test_sketch() + e_line = ExtensionLine( + outer.edges().sort_by(Axis.X)[0], offset=10, draft=metric + ) + bbox = e_line.bounding_box() + self.assertAlmostEqual(bbox.size.Y, 40 + metric.line_width, 5) + self.assertAlmostEqual(bbox.size.X, 10, 5) + + with self.assertRaises(ValueError): + ExtensionLine(outer.edges().sort_by(Axis.X)[0], offset=0, draft=metric) + + def test_builder_mode(self): + shape, outer, inner = create_test_sketch() + with BuildSketch() as test: + add(shape) + e_line = ExtensionLine( + outer.edges().sort_by(Axis.Y)[0], offset=10, draft=metric + ) + + bbox = e_line.bounding_box() + self.assertAlmostEqual(bbox.size.X, 30 + metric.line_width, 5) + self.assertAlmostEqual(bbox.size.Y, 10, 5) + + def test_not_implemented(self): + shape, outer, inner = create_test_sketch() + with self.assertRaises(NotImplementedError): + ExtensionLine( + outer.edges().sort_by(Axis.Y)[0], + offset=10, + project_line=(1, 0, 0), + draft=metric, + ) + + +class TestTechnicalDrawing(unittest.TestCase): + def test_basic_drawing(self): + with BuildSketch() as drawing: + TechnicalDrawing(design_date=date(2023, 9, 17), sheet_number=1) + bbox = drawing.sketch.bounding_box() + self.assertGreater(bbox.size.X, 280) + self.assertGreater(bbox.size.Y, 195) + self.assertGreater(len(drawing.faces()), 110) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_exporters.py b/tests/test_exporters.py new file mode 100644 index 00000000..bfa155f3 --- /dev/null +++ b/tests/test_exporters.py @@ -0,0 +1,140 @@ + +import unittest +import math +from typing import Union, Iterable +from build123d import ( + Mode, Shape, Plane, Locations, + BuildLine, Line, Bezier, RadiusArc, + BuildSketch, Sketch, make_face, RegularPolygon, Circle, PolarLocations, + BuildPart, Part, Cone, extrude, add, mirror, + section +) +from build123d.exporters import ( + ExportSVG, ExportDXF, Drawing, LineType +) + +class ExportersTestCase(unittest.TestCase): + + @staticmethod + def create_test_sketch() -> Sketch: + with BuildSketch() as sketchy: + with BuildLine(): + Line((0, 0), (16, 0)) + Bezier((16,0), (16,8), (16,8), (8,8)) + RadiusArc((8,8), (0,0), -8, short_sagitta=True) + make_face() + with Locations((5, 4)): + RegularPolygon(2, 4, mode=Mode.SUBTRACT) + with Locations((11, 4)): + Circle(2, mode=Mode.SUBTRACT) + return sketchy.sketch + + @staticmethod + def create_test_part() -> Part: + with BuildPart() as party: + add(ExportersTestCase.create_test_sketch()) + extrude(amount=4) + return party.part + + @staticmethod + def basic_svg_export(shape: Union[Shape, Iterable[Shape]], filename: str, reverse: bool = False): + svg = ExportSVG() + svg.add_shape(shape, reverse_wires=reverse) + svg.write(filename) + + @staticmethod + def basic_dxf_export(shape: Union[Shape, Iterable[Shape]], filename: str): + dxf = ExportDXF() + dxf.add_shape(shape) + dxf.write(filename) + + @staticmethod + def basic_combo_export(shape: Shape, filebase: str, reverse: bool = False): + ExportersTestCase.basic_svg_export(shape, filebase + ".svg", reverse) + ExportersTestCase.basic_dxf_export(shape, filebase + ".dxf") + + @staticmethod + def drawing_combo_export(dwg: Drawing, filebase: str): + svg = ExportSVG(line_weight=0.13) + svg.add_layer("hidden", line_weight=0.09, line_type=LineType.HIDDEN) + svg.add_shape(dwg.visible_lines) + svg.add_shape(dwg.hidden_lines, layer='hidden') + svg.write(filebase + ".svg") + dxf = ExportDXF(line_weight=0.13) + dxf.add_layer("hidden", line_weight=0.09, line_type=LineType.HIDDEN) + dxf.add_shape(dwg.visible_lines) + dxf.add_shape(dwg.hidden_lines, layer='hidden') + dxf.write(filebase + ".dxf") + + def test_sketch(self): + sketch = ExportersTestCase.create_test_sketch() + ExportersTestCase.basic_combo_export(sketch, "test-sketch") + + def test_drawing(self): + part = ExportersTestCase.create_test_part() + drawing = Drawing(part) + ExportersTestCase.drawing_combo_export(drawing, "test-drawing") + + def test_back_section_svg(self): + """Export a section through the bottom face. + This produces back facing wires to test the handling of + winding order.""" + part = ExportersTestCase.create_test_part() + test_section = section(part, Plane.XY, height=0) + ExportersTestCase.basic_svg_export(test_section, "test-back-section.svg", reverse=True) + + def test_angled_section(self): + """Export an angled section. + This tests more slightly more complex geometry.""" + part = ExportersTestCase.create_test_part() + angle = math.degrees(math.atan2(4, 8)) + section_plane = Plane.XY.rotated((angle, 0, 0)) + angled_section = section_plane.to_local_coords(section(part, section_plane)) + ExportersTestCase.basic_combo_export(angled_section, "test-angled-section") + + def test_cam_section_svg(self): + """ Export a section through the top face, with a simple + CAM oriented layer setup.""" + part = ExportersTestCase.create_test_part() + section_plane = Plane.XY.offset(4) + cam_section = section_plane.to_local_coords(section(part, section_plane)) + svg = ExportSVG() + white = (255,255,255) + black = (0,0,0) + svg.add_layer("exterior", line_color=black, fill_color=black) + svg.add_layer("interior", line_color=black, fill_color=white) + for f in cam_section.faces(): + svg.add_shape(f.outer_wire(), "exterior") + svg.add_shape(f.inner_wires(), "interior") + svg.write("test-cam-section.svg") + + def test_conic_section(self): + """Export a conic section. This tests a more "exotic" geometry + type.""" + cone = Cone(8, 0, 16, align=None) + section_plane = Plane.XZ.offset(4) + conic_section = section_plane.to_local_coords(section(cone, section_plane)) + ExportersTestCase.basic_combo_export(conic_section, "test-conic-section") + + def test_circle_rotation(self): + """Export faces with circular arcs in various orientations.""" + with BuildSketch() as sketch: + Circle(20) + Circle(8, mode=Mode.SUBTRACT) + with PolarLocations(20, 5, 90): + Circle(4, mode=Mode.SUBTRACT) + mirror(about=Plane.XZ.offset(25)) + ExportersTestCase.basic_combo_export(sketch.faces(), "test-circle-rotation") + + def test_ellipse_rotation(self): + """Export drawing with elliptical arcs in various orientations.""" + with BuildPart() as part: + with BuildSketch(Plane.ZX): + Circle(20) + Circle(8, mode=Mode.SUBTRACT) + with PolarLocations(20, 5, 90): + Circle(4, mode=Mode.SUBTRACT) + extrude(amount=20, both=True) + mirror(about=Plane.YZ.offset(25)) + drawing = Drawing(part.part) + ExportersTestCase.drawing_combo_export(drawing, "test-ellipse-rotation") diff --git a/tests/test_importers.py b/tests/test_importers.py new file mode 100644 index 00000000..aa46429c --- /dev/null +++ b/tests/test_importers.py @@ -0,0 +1,88 @@ +import os +import unittest +from build123d import ( + BuildLine, + Line, + Bezier, + RadiusArc, +) +from build123d.importers import import_svg_as_buildline_code, import_brep +from build123d.exporters import ExportSVG + + +class ImportSVG(unittest.TestCase): + def test_import_svg_as_buildline_code(self): + # Create svg file + with BuildLine() as test_obj: + l1 = Bezier((0, 0), (0.25, -0.1), (0.5, -0.15), (1, 0)) + l2 = Line(l1 @ 1, (1, 1)) + l3 = RadiusArc(l2 @ 1, (0, 1), 2) + l4 = Line(l3 @ 1, l1 @ 0) + svg = ExportSVG() + svg.add_shape(test_obj.wires()[0], "") + svg.write("test.svg") + + # Read the svg as code + buildline_code, builder_name = import_svg_as_buildline_code("test.svg") + + # Execute it and convert to Edges + ex_locals = {} + exec(buildline_code, None, ex_locals) + test_obj: BuildLine = ex_locals[builder_name] + found = 0 + for edge in test_obj.edges(): + if edge.geom_type() == "BEZIER": + found += 1 + elif edge.geom_type() == "LINE": + found += 1 + elif edge.geom_type() == "ELLIPSE": + found += 1 + self.assertEqual(found, 4) + os.remove("test.svg") + + def test_import_svg_as_buildline_code_invalid_name(self): + # Create svg file + with BuildLine() as test_obj: + l1 = Bezier((0, 0), (0.25, -0.1), (0.5, -0.15), (1, 0)) + l2 = Line(l1 @ 1, (1, 1)) + l3 = RadiusArc(l2 @ 1, (0, 1), 2) + l4 = Line(l3 @ 1, l1 @ 0) + svg = ExportSVG() + svg.add_shape(test_obj.wires()[0], "") + svg.write("test!.svg") + + # Read the svg as code + buildline_code, builder_name = import_svg_as_buildline_code("test!.svg") + os.remove("test!.svg") + + self.assertEqual(builder_name, "builder") + + # def test_import_svg_as_buildline_code_ccw(self): + # # Create svg file + # with BuildLine() as test_obj: + # l3 = RadiusArc((0, 1), (0, 0), 1.5) + # show(test_obj) + # svg = ExportSVG() + # svg.add_shape(test_obj.wires()[0], "") + # svg.write("test.svg") + + # # Read the svg as code + # buildline_code, builder_name = import_svg_as_buildline_code("test.svg") + + # # Execute it and convert to Edges + # ex_locals = {} + # exec(buildline_code, None, ex_locals) + # test_obj: BuildLine = ex_locals[builder_name] + # os.remove("test.svg") + + # self.assertEqual(test_obj.edges()[0].geom_type(), "ELLIPSE") + + +class ImportBREP(unittest.TestCase): + def test_bad_filename(self): + with self.assertRaises(ValueError): + import_brep("test.brep") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_joints.py b/tests/test_joints.py new file mode 100644 index 00000000..12456a51 --- /dev/null +++ b/tests/test_joints.py @@ -0,0 +1,463 @@ +""" +joints unittests + +name: test_joints.py +by: Gumyr +date: August 24, 2023 + +desc: + This python module contains the unittests for the Joint base and + derived classes. + +license: + + Copyright 2023 Gumyr + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +""" +import unittest + +from build123d.build_enums import CenterOf, GeomType +from build123d.build_part import BuildPart +from build123d.geometry import Axis, Location, Vector, VectorLike +from build123d.joints import ( + BallJoint, + CylindricalJoint, + LinearJoint, + RevoluteJoint, + RigidJoint, +) +from build123d.objects_part import Box, Cylinder, Sphere +from build123d.topology import Plane, Solid + + +class DirectApiTestCase(unittest.TestCase): + def assertTupleAlmostEquals( + self, + first: tuple[float, ...], + second: tuple[float, ...], + places: int, + msg: str = None, + ): + """Check Tuples""" + self.assertEqual(len(second), len(first)) + for i, j in zip(second, first): + self.assertAlmostEqual(i, j, places, msg=msg) + + def assertVectorAlmostEquals( + self, first: Vector, second: VectorLike, places: int, msg: str = None + ): + second_vector = Vector(second) + self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg) + self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg) + self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg) + + +class TestRigidJoint(DirectApiTestCase): + def test_rigid_joint(self): + base = Solid.make_box(1, 1, 1) + j1 = RigidJoint("top", base, Location(Vector(0.5, 0.5, 1))) + fixed_top = Solid.make_box(1, 1, 1) + j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0))) + j1.connect_to(j2) + bbox = fixed_top.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5) + self.assertVectorAlmostEquals(bbox.max, (1, 1, 2), 5) + + self.assertVectorAlmostEquals(j2.symbol.location.position, (0.5, 0.5, 1), 6) + self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6) + + def test_builder(self): + with BuildPart() as test: + Box(3, 3, 1) + RigidJoint("test") + Cylinder(1, 3) + + self.assertTrue(isinstance(test.part.joints["test"], RigidJoint)) + + def test_no_to_part(self): + with self.assertRaises(ValueError): + RigidJoint("test") + + def test_error_handling(self): + j1 = RigidJoint("one", Box(1, 1, 1)) + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1)) + + with self.assertRaises(TypeError): + j1.relative_to(Solid.make_box(1, 1, 1)) + + +class TestRevoluteJoint(DirectApiTestCase): + def test_revolute_joint_with_angle_reference(self): + revolute_base = Solid.make_cylinder(1, 1) + j1 = RevoluteJoint( + label="top", + to_part=revolute_base, + axis=Axis((0, 0, 1), (0, 0, 1)), + angle_reference=(1, 0, 0), + angular_range=(0, 180), + ) + fixed_top = Solid.make_box(1, 0.5, 1) + j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0))) + + j1.connect_to(j2, angle=90) + bbox = fixed_top.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-0.25, -0.5, 1), 5) + self.assertVectorAlmostEquals(bbox.max, (0.25, 0.5, 2), 5) + + self.assertVectorAlmostEquals(j2.symbol.location.position, (0, 0, 1), 6) + self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6) + self.assertEqual(len(j1.symbol.edges()), 2) + + def test_revolute_joint_without_angle_reference(self): + revolute_base = Solid.make_cylinder(1, 1) + j1 = RevoluteJoint( + label="top", + to_part=revolute_base, + axis=Axis((0, 0, 1), (0, 0, 1)), + ) + self.assertVectorAlmostEquals(j1.angle_reference, (1, 0, 0), 5) + + def test_revolute_joint_error_bad_angle_reference(self): + """Test that the angle_reference must be normal to the axis""" + revolute_base = Solid.make_cylinder(1, 1) + with self.assertRaises(ValueError): + RevoluteJoint( + "top", + revolute_base, + axis=Axis((0, 0, 1), (0, 0, 1)), + angle_reference=(1, 0, 1), + ) + + def test_revolute_joint_error_bad_angle(self): + """Test that the joint angle is within bounds""" + revolute_base = Solid.make_cylinder(1, 1) + j1 = RevoluteJoint("top", revolute_base, Axis.Z, angular_range=(0, 180)) + fixed_top = Solid.make_box(1, 0.5, 1) + j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.25, 0))) + with self.assertRaises(ValueError): + j1.connect_to(j2, angle=270) + + def test_revolute_joint_error_bad_joint_type(self): + """Test that the joint angle is within bounds""" + revolute_base = Solid.make_cylinder(1, 1) + j1 = RevoluteJoint("top", revolute_base, Axis.Z, (0, 180)) + fixed_top = Solid.make_box(1, 0.5, 1) + j2 = RevoluteJoint("bottom", fixed_top, Axis.Z, (0, 180)) + with self.assertRaises(TypeError): + j1.connect_to(j2, angle=0) + + def test_builder(self): + with BuildPart() as test: + Box(3, 3, 1) + RevoluteJoint("test") + Cylinder(1, 3) + + self.assertTrue(isinstance(test.part.joints["test"], RevoluteJoint)) + + def test_no_to_part(self): + with self.assertRaises(ValueError): + RevoluteJoint("test") + + +class TestLinearJoint(DirectApiTestCase): + def test_linear_rigid_joint(self): + base = Solid.make_box(1, 1, 1) + j1 = LinearJoint( + "top", to_part=base, axis=Axis((0, 0.5, 1), (1, 0, 0)), linear_range=(0, 1) + ) + fixed_top = Solid.make_box(1, 1, 1) + j2 = RigidJoint("bottom", fixed_top, Location((0.5, 0.5, 0))) + j1.connect_to(j2, position=0.25) + bbox = fixed_top.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (-0.25, 0, 1), 5) + self.assertVectorAlmostEquals(bbox.max, (0.75, 1, 2), 5) + + self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6) + self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 0), 6) + + def test_linear_revolute_joint(self): + linear_base = Solid.make_box(1, 1, 1) + j1 = LinearJoint( + label="top", + to_part=linear_base, + axis=Axis((0, 0.5, 1), (1, 0, 0)), + linear_range=(0, 1), + ) + revolute_top = Solid.make_box(1, 0.5, 1).locate(Location((-0.5, -0.25, 0))) + j2 = RevoluteJoint( + label="top", + to_part=revolute_top, + axis=Axis((0, 0, 0), (0, 0, 1)), + angle_reference=(1, 0, 0), + angular_range=(0, 180), + ) + j1.connect_to(j2, position=0.25, angle=90) + + bbox = revolute_top.bounding_box() + self.assertVectorAlmostEquals(bbox.min, (0, 0, 1), 5) + self.assertVectorAlmostEquals(bbox.max, (0.5, 1, 2), 5) + + self.assertVectorAlmostEquals(j2.symbol.location.position, (0.25, 0.5, 1), 6) + self.assertVectorAlmostEquals(j2.symbol.location.orientation, (0, 0, 90), 6) + self.assertEqual(len(j1.symbol.edges()), 2) + + # Test invalid position + with self.assertRaises(ValueError): + j1.connect_to(j2, position=5, angle=90) + + # Test invalid angle + with self.assertRaises(ValueError): + j1.connect_to(j2, position=0.5, angle=270) + + # Test invalid joint + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90) + + def test_builder(self): + with BuildPart() as test: + Box(3, 3, 1) + LinearJoint("test") + Cylinder(1, 3) + + self.assertTrue(isinstance(test.part.joints["test"], LinearJoint)) + + def test_no_to_part(self): + with self.assertRaises(ValueError): + LinearJoint("test") + + def test_error_handling(self): + j1 = LinearJoint("one", Box(1, 1, 1)) + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1)) + + with self.assertRaises(TypeError): + j1.relative_to(Solid.make_box(1, 1, 1)) + + +class TestCylindricalJoint(DirectApiTestCase): + def test_cylindrical_joint(self): + cylindrical_base = ( + Solid.make_box(1, 1, 1) + .locate(Location((-0.5, -0.5, 0))) + .cut(Solid.make_cylinder(0.3, 1)) + ) + j1 = CylindricalJoint( + "base", + cylindrical_base, + Axis((0, 0, 1), (0, 0, -1)), + angle_reference=(1, 0, 0), + linear_range=(0, 1), + angular_range=(0, 90), + ) + dowel = Solid.make_cylinder(0.3, 1).cut( + Solid.make_box(1, 1, 1).locate(Location((-0.5, 0, 0))) + ) + j2 = RigidJoint("bottom", dowel, Location((0, 0, 0), (0, 0, 0))) + j1.connect_to(j2, position=0.25, angle=90) + dowel_bbox = dowel.bounding_box() + self.assertVectorAlmostEquals(dowel_bbox.min, (0, -0.3, -0.25), 5) + self.assertVectorAlmostEquals(dowel_bbox.max, (0.3, 0.3, 0.75), 5) + + self.assertVectorAlmostEquals(j1.symbol.location.position, (0, 0, 1), 6) + self.assertVectorAlmostEquals( + j1.symbol.location.orientation, (-180, 0, -180), 6 + ) + self.assertEqual(len(j1.symbol.edges()), 2) + + # Test invalid position + with self.assertRaises(ValueError): + j1.connect_to(j2, position=5, angle=90) + + # Test invalid angle + with self.assertRaises(ValueError): + j1.connect_to(j2, position=0.5, angle=270) + + # Test invalid joint + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1), position=0.5, angle=90) + + def test_cylindrical_joint_error_bad_angle_reference(self): + """Test that the angle_reference must be normal to the axis""" + with self.assertRaises(ValueError): + CylindricalJoint( + "base", + Solid.make_box(1, 1, 1), + Axis((0, 0, 1), (0, 0, -1)), + angle_reference=(1, 0, 1), + linear_range=(0, 1), + angular_range=(0, 90), + ) + + def test_cylindrical_joint_error_bad_position_and_angle(self): + """Test that the joint angle is within bounds""" + + j1 = CylindricalJoint( + "base", + Solid.make_box(1, 1, 1), + Axis((0, 0, 1), (0, 0, -1)), + linear_range=(0, 1), + angular_range=(0, 90), + ) + j2 = RigidJoint("bottom", Solid.make_cylinder(1, 1), Location((0.5, 0.25, 0))) + with self.assertRaises(ValueError): + j1.connect_to(j2, position=0.5, angle=270) + + with self.assertRaises(ValueError): + j1.connect_to(j2, position=4, angle=30) + + def test_builder(self): + with BuildPart() as test: + Box(3, 3, 1) + CylindricalJoint("test") + Cylinder(1, 3) + + self.assertTrue(isinstance(test.part.joints["test"], CylindricalJoint)) + + def test_no_to_part(self): + with self.assertRaises(ValueError): + CylindricalJoint("test") + + def test_error_handling(self): + j1 = CylindricalJoint("one", Box(1, 1, 1)) + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1)) + + with self.assertRaises(TypeError): + j1.relative_to(Solid.make_box(1, 1, 1)) + + +class TestBallJoint(DirectApiTestCase): + def test_ball_joint(self): + socket_base = Solid.make_box(1, 1, 1).cut( + Solid.make_sphere(0.3, Plane((0.5, 0.5, 1))) + ) + j1 = BallJoint( + "socket", + socket_base, + Location((0.5, 0.5, 1)), + angular_range=((-45, 45), (-45, 45), (0, 360)), + ) + ball_rod = Solid.make_cylinder(0.15, 2).fuse( + Solid.make_sphere(0.3).locate(Location((0, 0, 2))) + ) + j2 = RigidJoint("ball", ball_rod, Location((0, 0, 2), (180, 0, 0))) + j1.connect_to(j2, angles=(45, 45, 0)) + self.assertVectorAlmostEquals( + ball_rod.faces().filter_by(GeomType.PLANE)[0].center(CenterOf.GEOMETRY), + (1.914213562373095, -0.5, 2), + 5, + ) + + self.assertVectorAlmostEquals(j1.symbol.location.position, (0.5, 0.5, 1), 6) + self.assertVectorAlmostEquals(j1.symbol.location.orientation, (0, 0, 0), 6) + + with self.assertRaises(ValueError): + j1.connect_to(j2, angles=(90, 45, 0)) + + # Test invalid joint + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1), angles=(0, 0, 0)) + + def test_builder(self): + with BuildPart() as test: + Box(3, 3, 1) + BallJoint("test") + Cylinder(1, 3) + + self.assertTrue(isinstance(test.part.joints["test"], BallJoint)) + + def test_no_to_part(self): + with self.assertRaises(ValueError): + BallJoint("test") + + def test_error_handling(self): + j1 = BallJoint("one", Box(1, 1, 1)) + with self.assertRaises(TypeError): + j1.connect_to(Solid.make_box(1, 1, 1)) + + with self.assertRaises(TypeError): + j1.relative_to(Solid.make_box(1, 1, 1)) + + +class TestJointOrder(DirectApiTestCase): + def test_rigid_rigid(self): + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = RigidJoint("two", Sphere(0.5)) + j1.connect_to(j2) + self.assertVectorAlmostEquals(j1.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j2.parent.location.position, (1, 2, 3), 5) + + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = RigidJoint("two", Sphere(0.5)) + j2.connect_to(j1) + self.assertVectorAlmostEquals(j2.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j1.parent.location.position, (-1, -2, -3), 5) + + def test_rigid_ball(self): + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = BallJoint("two", Sphere(0.5)) + j1.connect_to(j2, angles=(0, 0, 0)) + self.assertVectorAlmostEquals(j1.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j2.parent.location.position, (1, 2, 3), 5) + + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = BallJoint("two", Sphere(0.5)) + j2.connect_to(j1, angles=(0, 0, 0)) + self.assertVectorAlmostEquals(j2.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j1.parent.location.position, (-1, -2, -3), 5) + + def test_rigid_cylindrical(self): + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = CylindricalJoint("two", Sphere(0.5)) + j1.connect_to(j2, position=0, angle=0) + self.assertVectorAlmostEquals(j1.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j2.parent.location.position, (1, 2, 3), 5) + + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = CylindricalJoint("two", Sphere(0.5)) + j2.connect_to(j1, position=0, angle=0) + self.assertVectorAlmostEquals(j2.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j1.parent.location.position, (-1, -2, -3), 5) + + def test_rigid_linear(self): + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = LinearJoint("two", Sphere(0.5)) + j1.connect_to(j2, position=0) + self.assertVectorAlmostEquals(j1.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j2.parent.location.position, (1, 2, 3), 5) + + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = LinearJoint("two", Sphere(0.5)) + j2.connect_to(j1, position=0) + self.assertVectorAlmostEquals(j2.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j1.parent.location.position, (-1, -2, -3), 5) + + def test_rigid_revolute(self): + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = RevoluteJoint("two", Sphere(0.5)) + j1.connect_to(j2, angle=0) + self.assertVectorAlmostEquals(j1.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j2.parent.location.position, (1, 2, 3), 5) + + j1 = RigidJoint("one", Box(1, 1, 1), joint_location=Location((1, 2, 3))) + j2 = RevoluteJoint("two", Sphere(0.5)) + j2.connect_to(j1, angle=0) + self.assertVectorAlmostEquals(j2.parent.location.position, (0, 0, 0), 5) + self.assertVectorAlmostEquals(j1.parent.location.position, (-1, -2, -3), 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mesher.py b/tests/test_mesher.py new file mode 100644 index 00000000..47e3c41b --- /dev/null +++ b/tests/test_mesher.py @@ -0,0 +1,168 @@ +import os, unittest, uuid +from build123d.build_enums import MeshType, Unit +from build123d.topology import Compound, Solid +from build123d.geometry import Color, Vector, VectorLike, Location +from build123d.mesher import Mesher + + +class DirectApiTestCase(unittest.TestCase): + def assertTupleAlmostEquals( + self, + first: tuple[float, ...], + second: tuple[float, ...], + places: int, + msg: str = None, + ): + """Check Tuples""" + self.assertEqual(len(second), len(first)) + for i, j in zip(second, first): + self.assertAlmostEqual(i, j, places, msg=msg) + + def assertVectorAlmostEquals( + self, first: Vector, second: VectorLike, places: int, msg: str = None + ): + second_vector = Vector(second) + self.assertAlmostEqual(first.X, second_vector.X, places, msg=msg) + self.assertAlmostEqual(first.Y, second_vector.Y, places, msg=msg) + self.assertAlmostEqual(first.Z, second_vector.Z, places, msg=msg) + + +class TestProperties(unittest.TestCase): + def test_version(self): + exporter = Mesher() + self.assertEqual(exporter.library_version, "2.2.0") + + def test_units(self): + for unit in Unit: + exporter = Mesher(unit=unit) + exporter.add_shape(Solid.make_box(1, 1, 1)) + exporter.write("test.3mf") + importer = Mesher() + _shape = importer.read("test.3mf") + self.assertEqual(unit, importer.model_unit) + + def test_vertex_and_triangle_counts(self): + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + self.assertEqual(exporter.vertex_counts[0], 8) + self.assertEqual(exporter.triangle_counts[0], 12) + + def test_mesh_counts(self): + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + exporter.add_shape(Solid.make_cone(1, 0, 2)) + self.assertEqual(exporter.mesh_count, 2) + + +class TestMetaData(unittest.TestCase): + def test_add_meta_data(self): + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + exporter.add_meta_data("test_space", "test0", "some data", "str", True) + exporter.add_meta_data("test_space", "test1", "more data", "str", True) + exporter.write("test.3mf") + importer = Mesher() + _shape = importer.read("test.3mf") + imported_meta_data: list[dict] = importer.get_meta_data() + self.assertEqual(imported_meta_data[0]["name_space"], "test_space") + self.assertEqual(imported_meta_data[0]["name"], "test0") + self.assertEqual(imported_meta_data[0]["value"], "some data") + self.assertEqual(imported_meta_data[0]["type"], "str") + self.assertEqual(imported_meta_data[1]["name_space"], "test_space") + self.assertEqual(imported_meta_data[1]["name"], "test1") + self.assertEqual(imported_meta_data[1]["value"], "more data") + self.assertEqual(imported_meta_data[1]["type"], "str") + + def test_add_code(self): + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + exporter.add_code_to_metadata() + exporter.write("test.3mf") + importer = Mesher() + _shape = importer.read("test.3mf") + source_code = importer.get_meta_data_by_key("build123d", "test_mesher.py") + self.assertEqual(len(source_code), 2) + self.assertEqual(source_code["type"], "python") + self.assertGreater(len(source_code["value"]), 10) + + +class TestMeshProperties(unittest.TestCase): + def test_properties(self): + # Note: MeshType.OTHER can't be used with a Solid shape + for mesh_type in [MeshType.MODEL, MeshType.SUPPORT, MeshType.SOLIDSUPPORT]: + with self.subTest("MeshTYpe", mesh_type=mesh_type): + exporter = Mesher() + if mesh_type != MeshType.SUPPORT: + test_uuid = uuid.uuid1() + else: + test_uuid = None + name = "test" + mesh_type.name + shape = Solid.make_box(1, 1, 1) + shape.label = name + exporter.add_shape( + shape, + mesh_type=mesh_type, + part_number=str(mesh_type.value), + uuid_value=test_uuid, + ) + exporter.write("test.3mf") + importer = Mesher() + shape = importer.read("test.3mf") + self.assertEqual(shape[0].label, name) + self.assertEqual(importer.mesh_count, 1) + properties = importer.get_mesh_properties() + self.assertEqual(properties[0]["name"], name) + self.assertEqual(properties[0]["part_number"], str(mesh_type.value)) + self.assertEqual(properties[0]["type"], mesh_type.name) + if mesh_type != MeshType.SUPPORT: + self.assertEqual(properties[0]["uuid"], str(test_uuid)) + + +class TestAddShape(DirectApiTestCase): + def test_add_shape(self): + exporter = Mesher() + blue_shape = Solid.make_box(1, 1, 1) + blue_shape.color = Color("blue") + blue_shape.label = "blue" + red_shape = Solid.make_cone(1, 0, 2).locate(Location((0, -1, 0))) + red_shape.color = Color("red") + red_shape.label = "red" + exporter.add_shape([blue_shape, red_shape]) + exporter.write("test.3mf") + importer = Mesher() + box, cone = importer.read("test.3mf") + self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2) + self.assertVectorAlmostEquals(box.bounding_box().size, (1, 1, 1), 2) + self.assertEqual(len(box.clean().faces()), 6) + self.assertEqual(box.label, "blue") + self.assertEqual(cone.label, "red") + self.assertTupleAlmostEquals(box.color.to_tuple(), (0, 0, 1, 1), 5) + self.assertTupleAlmostEquals(cone.color.to_tuple(), (1, 0, 0, 1), 5) + + def test_add_compound(self): + exporter = Mesher() + box = Solid.make_box(1, 1, 1) + cone = Solid.make_cone(1, 0, 2).locate(Location((0, -1, 0))) + shape_assembly = Compound.make_compound([box, cone]) + exporter.add_shape(shape_assembly) + exporter.write("test.3mf") + importer = Mesher() + shapes = importer.read("test.3mf") + self.assertEqual(importer.mesh_count, 2) + + +class TestErrorChecking(unittest.TestCase): + def test_read_invalid_file(self): + with self.assertRaises(ValueError): + importer = Mesher() + importer.read("unknown_file.type") + + def test_write_invalid_file(self): + exporter = Mesher() + exporter.add_shape(Solid.make_box(1, 1, 1)) + with self.assertRaises(ValueError): + exporter.write("unknown_file.type") + + +if __name__ == "__main__": + unittest.main()