diff --git a/README.md b/README.md index 52e7d29..4ceef86 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,8 @@ The documentation for **cq_warehouse** can found at [readthedocs](https://cq-war To install **cq_warehouse** from github: ``` python3 -m pip install git+https://github.com/gumyr/cq_warehouse.git#egg=cq_warehouse +``` +To install the working `dev` branch: +``` +python3 -m pip install git+https://github.com/gumyr/cq_warehouse.git@dev#egg=cq_warehouse ``` \ No newline at end of file diff --git a/dist/cq_warehouse-0.0.1-py3-none-any.whl b/dist/cq_warehouse-0.0.1-py3-none-any.whl deleted file mode 100644 index 76dd078..0000000 Binary files a/dist/cq_warehouse-0.0.1-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.0.1.tar.gz b/dist/cq_warehouse-0.0.1.tar.gz deleted file mode 100644 index fe9dea1..0000000 Binary files a/dist/cq_warehouse-0.0.1.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.1.0-py3-none-any.whl b/dist/cq_warehouse-0.1.0-py3-none-any.whl deleted file mode 100644 index f119ba5..0000000 Binary files a/dist/cq_warehouse-0.1.0-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.1.0.tar.gz b/dist/cq_warehouse-0.1.0.tar.gz deleted file mode 100644 index a7b111f..0000000 Binary files a/dist/cq_warehouse-0.1.0.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.2.0-py3-none-any.whl b/dist/cq_warehouse-0.2.0-py3-none-any.whl deleted file mode 100644 index 66d1088..0000000 Binary files a/dist/cq_warehouse-0.2.0-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.2.0.tar.gz b/dist/cq_warehouse-0.2.0.tar.gz deleted file mode 100644 index ef75d62..0000000 Binary files a/dist/cq_warehouse-0.2.0.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.3.0-py3-none-any.whl b/dist/cq_warehouse-0.3.0-py3-none-any.whl deleted file mode 100644 index c9f7948..0000000 Binary files a/dist/cq_warehouse-0.3.0-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.3.0.tar.gz b/dist/cq_warehouse-0.3.0.tar.gz deleted file mode 100644 index 42376b9..0000000 Binary files a/dist/cq_warehouse-0.3.0.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.5.3-py3-none-any.whl b/dist/cq_warehouse-0.5.3-py3-none-any.whl deleted file mode 100644 index 8246015..0000000 Binary files a/dist/cq_warehouse-0.5.3-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.5.3.tar.gz b/dist/cq_warehouse-0.5.3.tar.gz deleted file mode 100644 index 454179c..0000000 Binary files a/dist/cq_warehouse-0.5.3.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.6.0-py3-none-any.whl b/dist/cq_warehouse-0.6.0-py3-none-any.whl deleted file mode 100644 index 8c7bb0b..0000000 Binary files a/dist/cq_warehouse-0.6.0-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.6.0.tar.gz b/dist/cq_warehouse-0.6.0.tar.gz deleted file mode 100644 index 6ab01f8..0000000 Binary files a/dist/cq_warehouse-0.6.0.tar.gz and /dev/null differ diff --git a/dist/cq_warehouse-0.7.0-py3-none-any.whl b/dist/cq_warehouse-0.7.0-py3-none-any.whl deleted file mode 100644 index e69bb36..0000000 Binary files a/dist/cq_warehouse-0.7.0-py3-none-any.whl and /dev/null differ diff --git a/dist/cq_warehouse-0.7.0.tar.gz b/dist/cq_warehouse-0.7.0.tar.gz deleted file mode 100644 index cdc106e..0000000 Binary files a/dist/cq_warehouse-0.7.0.tar.gz and /dev/null differ diff --git a/docs/extensions.rst b/docs/extensions.rst index c128f6c..01c6b74 100644 --- a/docs/extensions.rst +++ b/docs/extensions.rst @@ -41,8 +41,9 @@ Currently, cq_warehouse.extensions augments these four CadQuery source files: * assembly.py, * cq.py, -* geom.py, and -* shapes.py. +* geom.py, +* shapes.py, and +* sketch.py. Usage: @@ -113,6 +114,13 @@ Shape class extensions .. autoclass:: Shape :members: +*********************** +Sketch class extensions +*********************** + +.. autoclass:: Sketch + :members: + *********************** Vector class extensions *********************** diff --git a/docs/finger_jointed_boxes.rst b/docs/finger_jointed_boxes.rst index 5a8b124..5ca9fd1 100644 --- a/docs/finger_jointed_boxes.rst +++ b/docs/finger_jointed_boxes.rst @@ -79,6 +79,7 @@ The full API is as follows: :noindex: .. automethod:: Workplane.makeFingerJoints + :noindex: The ``kerfWidth`` parameter can be used to compensate for the size of the laser cut thus allowing a path for the laser to the created directly from the face. Check with diff --git a/examples/embossing.py b/examples/embossing.py index bb7ae64..5708c6f 100644 --- a/examples/embossing.py +++ b/examples/embossing.py @@ -83,23 +83,27 @@ class Testcase(Enum): 50, 100, pnt=cq.Vector(0, 0, -50), dir=cq.Vector(0, 0, 1) ) path = cq.Workplane(target_object).section().edges().val() - to_emboss_wire = cq.Wire.makeRect( - 80, 40, cq.Vector(), cq.Vector(0, 0, 1), cq.Vector(1, 0, 0) - ) + # to_emboss_wire = cq.Wire.makeRect( + # 80, 40, cq.Vector(), cq.Vector(0, 0, 1), cq.Vector(1, 0, 0) + # ) + to_emboss_wire = cq.Workplane("XY").slot2D(80, 40).wires().val() embossed_wire = to_emboss_wire.embossToShape( targetObject=target_object, surfacePoint=path.positionAt(0), surfaceXDirection=path.tangentAt(0), tolerance=0.1, ) - embossed_edges = embossed_wire.Edges() - for i, e in enumerate(to_emboss_wire.Edges()): + embossed_edges = embossed_wire.sortedEdges() + for i, e in enumerate(to_emboss_wire.sortedEdges()): target = e.Length() actual = embossed_edges[i].Length() print( f"Edge lengths: target {target}, actual {actual}, difference {abs(target-actual)}" ) - + sweep_profile = cq.Wire.makeCircle( + 3, center=embossed_wire.positionAt(0), normal=embossed_wire.tangentAt(0) + ) + swept_wire = cq.Solid.sweep(sweep_profile, [], path=embossed_wire) print(f"Example #{example} time: {timeit.default_timer() - starttime:0.2f}s") if "show_object" in locals(): @@ -107,3 +111,4 @@ class Testcase(Enum): show_object(path, name="path") show_object(to_emboss_wire, name="to_emboss_wire") show_object(embossed_wire, name="embossed_wire") + show_object(swept_wire, name="swept_wire") diff --git a/examples/text_on_path.py b/examples/text_on_path.py index 87c9a27..61791a0 100644 --- a/examples/text_on_path.py +++ b/examples/text_on_path.py @@ -2,11 +2,11 @@ Extensions Examples -name: text_on_path.py +name: txt_on_path.py by: Gumyr date: January 10th 2022 -desc: Create 3D text on a path on many planes. +desc: Create 3D txt on a path on many planes. license: @@ -53,7 +53,7 @@ txt=base_plane + " The quick brown fox jumped over the lazy dog", fontsize=5, distance=1, - start=0.05, + positionOnPath=0.05, ) ) if "show_object" in locals(): diff --git a/scripts/build_cadquery_patch.py b/scripts/build_cadquery_patch.py index 21fb045..d8c67c9 100644 --- a/scripts/build_cadquery_patch.py +++ b/scripts/build_cadquery_patch.py @@ -17,6 +17,7 @@ and generates extended versions of these files: - assembly.py, - cq.py, + - sketch.py, - geom.py, and - shapes.py. Finally, a diff is generated between the originals and extended files for use @@ -67,6 +68,7 @@ class_files = { "occ_impl/shapes.py": ["Shape", "Vertex", "Edge", "Wire", "Face", "Module"], "assembly.py": ["Assembly"], + "sketch.py": ["Sketch"], "cq.py": ["Workplane"], "occ_impl/geom.py": ["Plane", "Vector", "Location"], } @@ -446,6 +448,12 @@ def main(argv): ], stdout=patch_file, ) + # Extract the second line to determine the patch depth + with open(patch_file_name, "r") as patch_file: + patch_file.readline() + first_line = patch_file.readline() + patch_depth = first_line.split()[1].count("/") + # Copy the patch to the cadquery original source directory shutil.copyfile( patch_file_name, @@ -457,9 +465,9 @@ def main(argv): ) print("To apply the patch:") print(f" cd {cadquery_path}") - print(f" patch -s -p4 < {patch_file_name}") + print(f" patch -s -l -p{patch_depth} < {patch_file_name}") print("To reverse the patch:") - print(f" patch -R -p4 < {patch_file_name}") + print(f" patch -R -l -p{patch_depth} < {patch_file_name}") if __name__ == "__main__": diff --git a/scripts/build_extensions_doc.py b/scripts/build_extensions_doc.py index fd2d46c..dc91bb1 100644 --- a/scripts/build_extensions_doc.py +++ b/scripts/build_extensions_doc.py @@ -17,8 +17,9 @@ and generates extended versions of these files: - assembly.py, - cq.py, - - geom.py, and - - shapes.py. + - geom.py, + - shapes.py, and + - sketch.py. Finally, a diff is generated between the originals and extended files for use with the patch command. @@ -303,6 +304,7 @@ def main(argv): "class Solid:\n pass\n", "class Compound:\n pass\n", "class Location:\n pass\n", + "Modes = Literal['a', 's', 'i', 'c']\n", ] ) for class_name, method_dictionaries in extensions_code_dictionary.items(): diff --git a/setup.cfg b/setup.cfg index 4e49bc6..6a0cf3a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = cq_warehouse -version = 0.7.0 +version = 0.7.1 author = Gumyr author_email = gumyr9@gmail.com description = A cadquery parametric part collection diff --git a/src/cq_warehouse.egg-info/PKG-INFO b/src/cq_warehouse.egg-info/PKG-INFO index b0a81f3..b168526 100644 --- a/src/cq_warehouse.egg-info/PKG-INFO +++ b/src/cq_warehouse.egg-info/PKG-INFO @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: cq-warehouse -Version: 0.7.0 +Version: 0.7.1 Summary: A cadquery parametric part collection Home-page: https://github.com/gumyr/cq_warehouse Author: Gumyr diff --git a/src/cq_warehouse.egg-info/SOURCES.txt b/src/cq_warehouse.egg-info/SOURCES.txt index 367b1d2..3d1d0f2 100644 --- a/src/cq_warehouse.egg-info/SOURCES.txt +++ b/src/cq_warehouse.egg-info/SOURCES.txt @@ -44,6 +44,7 @@ src/cq_warehouse/single_row_capped_deep_groove_ball_bearing_parameters.csv src/cq_warehouse/single_row_cylindrical_roller_bearing_parameters.csv src/cq_warehouse/single_row_deep_groove_ball_bearing_parameters.csv src/cq_warehouse/single_row_tapered_roller_bearing_parameters.csv +src/cq_warehouse/sketch_extended.py src/cq_warehouse/socket_head_cap_parameters.csv src/cq_warehouse/sprocket.py src/cq_warehouse/square_nut_parameters.csv diff --git a/src/cq_warehouse/extensions.py b/src/cq_warehouse/extensions.py index 286110c..5ff4acf 100644 --- a/src/cq_warehouse/extensions.py +++ b/src/cq_warehouse/extensions.py @@ -31,6 +31,7 @@ limitations under the License. """ +from cmath import isnan import sys import logging import math @@ -39,6 +40,7 @@ from functools import reduce from token import OP from typing import Optional, Literal, Union, Tuple, Iterable +from types import MethodType import cadquery as cq from cadquery.occ_impl.shapes import VectorLike from cadquery.cq import T @@ -60,11 +62,11 @@ DirectionMinMaxSelector, Color, ) +from cadquery.sketch import Modes from cq_warehouse.fastener import ( Screw, Nut, Washer, - HeatSetNut, DomedCapNut, HexNut, UnchamferedHexagonNut, @@ -92,13 +94,23 @@ from OCP.Standard import Standard_NoSuchObject from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.gp import gp_Vec, gp_Pnt, gp_Ax1, gp_Dir, gp_Trsf, gp, gp_GTrsf +from OCP.Font import ( + Font_FontMgr, + Font_FA_Regular, + Font_FA_Italic, + Font_FA_Bold, + Font_SystemFont, +) +from OCP.TCollection import TCollection_AsciiString +from OCP.StdPrs import StdPrs_BRepFont, StdPrs_BRepTextBuilder as Font_BRepTextBuilder +from OCP.NCollection import NCollection_Utf8String # Logging configuration - all cq_warehouse logs are level DEBUG or WARNING logging.basicConfig( filename="cq_warehouse.log", encoding="utf-8", - # level=logging.DEBUG, - level=logging.CRITICAL, + level=logging.DEBUG, + # level=logging.CRITICAL, format="%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)s - %(funcName)20s() ] - %(message)s", ) @@ -631,14 +643,15 @@ def _textOnPath( txt: str, fontsize: float, distance: float, - start: float = 0.0, cut: bool = True, combine: bool = False, clean: bool = True, font: str = "Arial", fontPath: Optional[str] = None, kind: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "left", valign: Literal["center", "top", "bottom"] = "center", + positionOnPath: float = 0.0, ) -> T: """ Returns 3D text with the baseline following the given path. @@ -659,13 +672,15 @@ def _textOnPath( txt: text to be rendered fontsize: size of the font in model units distance: the distance to extrude or cut, normal to the workplane plane, negative means opposite the normal direction - start: the relative location on path to start the text, values must be between 0.0 and 1.0 cut: True to cut the resulting solid from the parent solids if found combine: True to combine the resulting solid with parent solids if found clean: call :py:meth:`clean` afterwards to have a clean shape font: font name fontPath: path to font file - kind: font type + kind: font style + halign: horizontal alignment + valign: vertical alignment + positionOnPath: the relative location on path to position the text, values must be between 0.0 and 1.0 Returns: a CQ object with the resulting solid selected @@ -705,70 +720,28 @@ def _textOnPath( ) ) """ - - # from .selectors import DirectionMinMaxSelector - - def position_face(orig_face: "Face") -> "Face": - """ - Reposition a face to the provided path - - Local coordinates are used to calculate the position of the face - relative to the path. Global coordinates to position the face. - """ - bbox = self.plane.toLocalCoords(orig_face.BoundingBox()) - face_bottom_center = Vector((bbox.xmin + bbox.xmax) / 2, 0, 0) - relative_position_on_wire = start + face_bottom_center.x / path_length - wire_tangent = path.tangentAt(relative_position_on_wire) - wire_angle = ( - 180 - * self.plane.xDir.getSignedAngle(wire_tangent, self.plane.zDir) - / math.pi - ) - - wire_position = path.positionAt(relative_position_on_wire) - global_face_bottom_center = self.plane.toWorldCoords(face_bottom_center) - return orig_face.translate(wire_position - global_face_bottom_center).rotate( - wire_position, - wire_position + self.plane.zDir, - wire_angle, - ) - # The top edge or wire on the stack defines the path if not self.ctx.pendingWires and not self.ctx.pendingEdges: raise Exception("A pending edge or wire must be present to define the path") for stack_object in self.vals(): if type(stack_object) == Edge: - path = self.ctx.pendingEdges.pop(0) + path = Wire.assembleEdges(self.ctx.pendingEdges) break if type(stack_object) == Wire: path = self.ctx.pendingWires.pop(0) break - # Create text on the current workplane - raw_text = Compound.makeText( - txt, - fontsize, - distance, - font=font, - fontPath=fontPath, - kind=kind, - halign="left", - valign=valign, - position=self.plane, - ) - # Extract just the faces on the workplane - text_faces = ( - Workplane(raw_text) - .faces(DirectionMinMaxSelector(self.plane.zDir, False)) - .vals() - ) - path_length = path.Length() - - # Reposition all of the text faces and re-create 3D text - faces_on_path = [position_face(f) for f in text_faces] - result = Compound.makeCompound( - [Solid.extrudeLinear(f, self.plane.zDir) for f in faces_on_path] + # The path was defined on an arbitrary plane, convert back to XY + local_path = self.plane.toLocalCoords(path) + result = Compound.make2DText( + txt, fontsize, font, fontPath, kind, halign, valign, positionOnPath, local_path ) + if distance != 0: + result = Compound.makeCompound( + [Solid.extrudeLinear(f, Vector(0, 0, distance)) for f in result.Faces()] + ) + # Reposition on this workplane + result = result.transformShape(self.plane.rG) if cut: combine = "cut" @@ -1760,6 +1733,9 @@ def _face_makeHoles(self, interiorWires: list["Wire"]) -> "Face": .. image:: slotted_cylinder.png + Args: + interiorWires: a list of hole outline wires + Raises: RuntimeError: adding interior hole in non-planar face with provided interiorWires RuntimeError: resulting face is not valid @@ -1969,6 +1945,383 @@ def lenCornerFaceCounter(corner: Vertex) -> int: Face.makeFingerJoints = _makeFingerJoints_face +""" + +Compound extensions: make2DText + +""" + + +def _make2DText_compound( + cls, + txt: str, + fontsize: float, + font: str = "Arial", + fontPath: Optional[str] = None, + fontStyle: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "left", + valign: Literal["center", "top", "bottom"] = "center", + positionOnPath: float = 0.0, + textPath: Union["Edge", "Wire"] = None, +) -> "Compound": + """ + 2D Text that optionally follows a path. + + The text that is created can be combined as with other sketch features by specifying + a mode or rotated by the given angle. In addition, edges have been previously created + with arc or segment, the text will follow the path defined by these edges. The start + parameter can be used to shift the text along the path to achieve precise positioning. + + Args: + txt: text to be rendered + fontsize: size of the font in model units + font: font name + fontPath: path to font file + fontStyle: one of ["regular", "bold", "italic"]. Defaults to "regular". + halign: horizontal alignment, one of ["center", "left", "right"]. + Defaults to "left". + valign: vertical alignment, one of ["center", "top", "bottom"]. + Defaults to "center". + positionOnPath: the relative location on path to position the text, between 0.0 and 1.0. + Defaults to 0.0. + textPath: a path for the text to follows. Defaults to None - linear text. + + Returns: + a Compound object containing multiple Faces representing the text + + Examples:: + + fox = cq.Compound.make2DText( + txt="The quick brown fox jumped over the lazy dog", + fontsize=10, + positionOnPath=0.1, + textPath=jump_edge, + ) + + """ + + def position_face(orig_face: "Face") -> "Face": + """ + Reposition a face to the provided path + + Local coordinates are used to calculate the position of the face + relative to the path. Global coordinates to position the face. + """ + bbox = orig_face.BoundingBox() + face_bottom_center = Vector((bbox.xmin + bbox.xmax) / 2, 0, 0) + relative_position_on_wire = positionOnPath + face_bottom_center.x / path_length + wire_tangent = textPath.tangentAt(relative_position_on_wire) + wire_angle = -180 * Vector(1, 0, 0).getSignedAngle(wire_tangent) / math.pi + wire_position = textPath.positionAt(relative_position_on_wire) + + return orig_face.translate(wire_position - face_bottom_center).rotate( + wire_position, + wire_position + Vector(0, 0, 1), + wire_angle, + ) + + font_kind = { + "regular": Font_FA_Regular, + "bold": Font_FA_Bold, + "italic": Font_FA_Italic, + }[fontStyle] + + mgr = Font_FontMgr.GetInstance_s() + + if fontPath and mgr.CheckFont(TCollection_AsciiString(fontPath).ToCString()): + font_t = Font_SystemFont(TCollection_AsciiString(fontPath)) + font_t.SetFontPath(font_kind, TCollection_AsciiString(fontPath)) + mgr.RegisterFont(font_t, True) + + else: + font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind) + + builder = Font_BRepTextBuilder() + font_i = StdPrs_BRepFont( + NCollection_Utf8String(font_t.FontName().ToCString()), + font_kind, + float(fontsize), + ) + text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt))) + + bb = text_flat.BoundingBox() + + t = Vector() + + if halign == "center": + t.x = -bb.xlen / 2 + elif halign == "right": + t.x = -bb.xlen + + if valign == "center": + t.y = -bb.ylen / 2 + elif valign == "top": + t.y = -bb.ylen + + text_flat = text_flat.translate(t) + + if textPath is not None: + path_length = textPath.Length() + text_flat = Compound.makeCompound([position_face(f) for f in text_flat.Faces()]) + + return text_flat + + +# Monkey patch a class method +Compound.make2DText = MethodType(_make2DText_compound, Compound) + +""" + +Sketch extensions: text(), val(), vals(), add(), pushCenter() + +""" + + +def _text_sketch( + self: T, + txt: str, + fontsize: float, + font: str = "Arial", + fontPath: Optional[str] = None, + fontStyle: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "left", + valign: Literal["center", "top", "bottom"] = "center", + positionOnPath: float = 0.0, + angle: float = 0, + mode: "Modes" = "a", + tag: Optional[str] = None, +) -> T: + """ + Text that optionally follows a path. + + The text that is created can be combined as with other sketch features by specifying + a mode or rotated by the given angle. In addition, the text will follow the path defined + by edges that have been previously created with arc or segment. The positionOnPath + parameter can be used to shift the text along the path to achieve precise positioning. + + Examples:: + + simple_text = cq.Sketch().text("simple", 10, angle=10) + + loop_sketch = ( + cq.Sketch() + .arc((-50, 0), 50, 90, 270) + .arc((50, 0), 50, 270, 270) + .text("loop_" * 20, 10) + ) + + Args: + txt: text to be rendered + fontsize: size of the font in model units + font: font name + fontPath: system path to font file + fontStyle: one of ["regular", "bold", "italic"]. Defaults to "regular". + halign: horizontal alignment, one of ["center", "left", "right"]. + Defaults to "left". + valign: vertical alignment, one of ["center", "top", "bottom"]. + Defaults to "center". + positionOnPath: the relative location on path to locate the text, between 0.0 and 1.0. + Defaults to 0.0. + angle: rotation angle. Defaults to 0.0. + mode: combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag: feature label. Defaults to None. + + Returns: + a Sketch object + + + """ + if self._edges: + text_path = Wire.assembleEdges(self._edges) + else: + text_path = None + + res = Compound.make2DText( + txt, + fontsize, + font, + fontPath, + fontStyle, + halign, + valign, + positionOnPath, + text_path, + ).rotate(Vector(), Vector(0, 0, 1), angle) + + return self.each(lambda l: res.located(l), mode, tag) + + +Sketch.text = _text_sketch + + +def _vals_sketch(self) -> list[Union["Vertex", "Wire", "Edge", "Face"]]: + """Return a list of selected values + + Examples:: + + face_objects = cq.Sketch().text("test", 10).faces().vals() + + Raises: + ValueError: Nothing selected + + Returns: + list[Union[Vertex, Wire, Edge, Face]]: List of selected occ_impl objects + + """ + if not self._selection: + raise ValueError("Nothing selected") + return self._selection + + +Sketch.vals = _vals_sketch + + +def _val_sketch(self) -> Union["Vertex", "Wire", "Edge", "Face"]: + """Return the first selected value + + Examples:: + + edge_object = cq.Sketch().arc((-50, 0), 50, 90, 270).edges().val() + + Raises: + ValueError: Nothing selected + + Returns: + Union[Vertex, Wire, Edge, Face]: The first selected occ_impl object + + """ + if not self._selection: + raise ValueError("Nothing selected") + return self._selection[0] + + +Sketch.val = _val_sketch + + +def _add_sketch( + self: T, + obj: Union["Wire", "Edge", "Face"], + angle: float = 0, + mode: "Modes" = "a", + tag: Optional[str] = None, +) -> T: + """add + + Add a Wire, Edge or Face to this sketch + + Examples:: + + added_edge = cq.Sketch().arc((50, 0), 50, 270, 270).add(external_edge).assemble() + + Args: + obj (Union[Wire, Edge, Face]): the object to add + angle (float, optional): rotation angle. Defaults to 0.0. + mode (Modes, optional): combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag (Optional[str], optional): feature label. Defaults to None. + + Returns: + Updated sketch + + """ + if isinstance(obj, Edge): + self.edge(obj.rotate(Vector(), Vector(0, 0, 1), angle), tag, mode == "c") + elif isinstance(obj, Wire): + obj.forConstruction = mode == "c" + self._wires.append(obj.rotate(Vector(), Vector(0, 0, 1), angle)) + if tag: + self._tag([obj], tag) + elif isinstance(obj, Face): + self.each( + lambda l: obj.rotate(Vector(), Vector(0, 0, 1), angle).located(l), + mode, + tag, + ) + + return self + + +Sketch.add = _add_sketch + + +def _boundingBox_sketch( + self: T, + mode: "Modes" = "a", + tag: Optional[str] = None, +) -> T: + """Bounding Box + + Create bounding box(s) around selected features. These bounding boxes can + be used to directly construct shapes or to locate other shapes. + + Examples:: + + mickey = ( + cq.Sketch() + .circle(10) + .faces() + .boundingBox(tag="bb", mode="c") + .faces(tag="bb") + .vertices(">Y") + .circle(7) + .clean() + ) + + bounding_box_center = ( + cq.Sketch() + .segment((0, 0), (10, 0)) + .segment((0, 5)) + .close() + .assemble(tag="t") + .faces(tag="t") + .circle(0.5, mode="s") + .faces(tag="t") + .boundingBox(tag="bb", mode="c") + .faces(tag="bb") + .rect(1, 1, mode="s") + ) + + circles = ( + cq.Sketch() + .rarray(40, 40, 2, 2) + .circle(10) + .reset() + .faces() + .boundingBox(tag="bb", mode="c") + .vertices(tag="bb") + .circle(7) + .clean() + ) + + Args: + mode (Modes, optional): combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag (Optional[str], optional): feature label. Defaults to None. + + Returns: + Updated sketch + """ + bb_faces = [] + for obj in self._selection: + if isinstance(obj, Vertex): + continue + bb = obj.BoundingBox() + vertices = [ + (bb.xmin, bb.ymin), + (bb.xmin, bb.ymax), + (bb.xmax, bb.ymax), + (bb.xmax, bb.ymin), + (bb.xmin, bb.ymin), + ] + bb_faces.append( + Face.makeFromWires(Wire.makePolygon([Vector(v) for v in vertices])) + ) + bb_faces_iter = iter(bb_faces) + self.push([(0, 0)] * len(bb_faces)) + self.each(lambda loc: next(bb_faces_iter).located(loc), mode, tag) + + return self + + +Sketch.boundingBox = _boundingBox_sketch """ @@ -2261,7 +2614,8 @@ def _embossWireToShape( """ import warnings - planar_edges = self.Edges() + # planar_edges = self.Edges() + planar_edges = self.sortedEdges() for i, planar_edge in enumerate(planar_edges[:-1]): if ( planar_edge.positionAt(1) - planar_edges[i + 1].positionAt(0) @@ -2353,6 +2707,42 @@ def _embossWireToShape( Wire.embossToShape = _embossWireToShape + +def _sortedEdges_wire(self, tolerance: float = 1e-5): + """Edges sorted by position + + Extract the edges from the wire and sort them such that the end of one + edge is within tolerance of the start of the next edge + + Args: + tolerance (float, optional): Max separation between sequential edges. + Defaults to 1e-5. + + Raises: + ValueError: Wire is disjointed + + Returns: + list(Edge): Edges sorted by position + """ + unsorted_edges = self.Edges() + sorted_edges = [unsorted_edges.pop(0)] + while unsorted_edges: + found = False + for i in range(len(unsorted_edges)): + if ( + sorted_edges[-1].positionAt(1) - unsorted_edges[i].positionAt(0) + ).Length < tolerance: + sorted_edges.append(unsorted_edges.pop(i)) + found = True + break + if not found: + raise ValueError("Edge segments are separated by tolerance or more") + + return sorted_edges + + +Wire.sortedEdges = _sortedEdges_wire + """ Edge extensions: projectToShape(), embossToShape() @@ -3005,7 +3395,7 @@ def _location_str(self): Location as String """ loc_tuple = self.toTuple() - return f"({str(loc_tuple[0])}, {str(loc_tuple[1])})" + return f"Location: ({str(loc_tuple[0])}, {str(loc_tuple[1])})" Location.__str__ = _location_str diff --git a/src/cq_warehouse/extensions_doc.py b/src/cq_warehouse/extensions_doc.py index ec8e3c2..c8387b1 100644 --- a/src/cq_warehouse/extensions_doc.py +++ b/src/cq_warehouse/extensions_doc.py @@ -15,6 +15,7 @@ class Compound: pass class Location: pass +Modes = Literal['a', 's', 'i', 'c'] class Assembly(object): def translate(self, vec: "VectorLike") -> "Assembly": """ @@ -251,14 +252,15 @@ def textOnPath( txt: str, fontsize: float, distance: float, - start: float = 0.0, cut: bool = True, combine: bool = False, clean: bool = True, font: str = "Arial", fontPath: Optional[str] = None, kind: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "left", valign: Literal["center", "top", "bottom"] = "center", + positionOnPath: float = 0.0, ) -> T: """ Returns 3D text with the baseline following the given path. @@ -279,13 +281,15 @@ def textOnPath( txt: text to be rendered fontsize: size of the font in model units distance: the distance to extrude or cut, normal to the workplane plane, negative means opposite the normal direction - start: the relative location on path to start the text, values must be between 0.0 and 1.0 cut: True to cut the resulting solid from the parent solids if found combine: True to combine the resulting solid with parent solids if found clean: call :py:meth:`clean` afterwards to have a clean shape font: font name fontPath: path to font file - kind: font type + kind: font style + halign: horizontal alignment + valign: vertical alignment + positionOnPath: the relative location on path to position the text, values must be between 0.0 and 1.0 Returns: a CQ object with the resulting solid selected @@ -762,6 +766,9 @@ def makeHoles(self, interiorWires: list["Wire"]) -> "Face": .. image:: slotted_cylinder.png + Args: + interiorWires: a list of hole outline wires + Raises: RuntimeError: adding interior hole in non-planar face with provided interiorWires RuntimeError: resulting face is not valid @@ -813,6 +820,170 @@ def makeFingerJoints( Returns: Face: the Face with notches on one edge """ +class Sketch(object): + def text( + self: T, + txt: str, + fontsize: float, + font: str = "Arial", + fontPath: Optional[str] = None, + fontStyle: Literal["regular", "bold", "italic"] = "regular", + halign: Literal["center", "left", "right"] = "left", + valign: Literal["center", "top", "bottom"] = "center", + positionOnPath: float = 0.0, + angle: float = 0, + mode: "Modes" = "a", + tag: Optional[str] = None, + ) -> T: + """ + Text that optionally follows a path. + + The text that is created can be combined as with other sketch features by specifying + a mode or rotated by the given angle. In addition, the text will follow the path defined + by edges that have been previously created with arc or segment. The positionOnPath + parameter can be used to shift the text along the path to achieve precise positioning. + + Examples:: + + simple_text = cq.Sketch().text("simple", 10, angle=10) + + loop_sketch = ( + cq.Sketch() + .arc((-50, 0), 50, 90, 270) + .arc((50, 0), 50, 270, 270) + .text("loop_" * 20, 10) + ) + + Args: + txt: text to be rendered + fontsize: size of the font in model units + font: font name + fontPath: system path to font file + fontStyle: one of ["regular", "bold", "italic"]. Defaults to "regular". + halign: horizontal alignment, one of ["center", "left", "right"]. + Defaults to "left". + valign: vertical alignment, one of ["center", "top", "bottom"]. + Defaults to "center". + positionOnPath: the relative location on path to locate the text, between 0.0 and 1.0. + Defaults to 0.0. + angle: rotation angle. Defaults to 0.0. + mode: combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag: feature label. Defaults to None. + + Returns: + a Sketch object + + + """ + def vals(self) -> list[Union["Vertex", "Wire", "Edge", "Face"]]: + """Return a list of selected values + + Examples:: + + face_objects = cq.Sketch().text("test", 10).faces().vals() + + Raises: + ValueError: Nothing selected + + Returns: + list[Union[Vertex, Wire, Edge, Face]]: List of selected occ_impl objects + + """ + def val(self) -> Union["Vertex", "Wire", "Edge", "Face"]: + """Return the first selected value + + Examples:: + + edge_object = cq.Sketch().arc((-50, 0), 50, 90, 270).edges().val() + + Raises: + ValueError: Nothing selected + + Returns: + Union[Vertex, Wire, Edge, Face]: The first selected occ_impl object + + """ + def add( + self: T, + obj: Union["Wire", "Edge", "Face"], + angle: float = 0, + mode: "Modes" = "a", + tag: Optional[str] = None, + ) -> T: + """add + + Add a Wire, Edge or Face to this sketch + + Examples:: + + added_edge = cq.Sketch().arc((50, 0), 50, 270, 270).add(external_edge).assemble() + + Args: + obj (Union[Wire, Edge, Face]): the object to add + angle (float, optional): rotation angle. Defaults to 0.0. + mode (Modes, optional): combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag (Optional[str], optional): feature label. Defaults to None. + + Returns: + Updated sketch + + """ + def boundingBox( + self: T, + mode: "Modes" = "a", + tag: Optional[str] = None, + ) -> T: + """Bounding Box + + Create bounding box(s) around selected features. These bounding boxes can + be used to directly construct shapes or to locate other shapes. + + Examples:: + + mickey = ( + cq.Sketch() + .circle(10) + .faces() + .boundingBox(tag="bb", mode="c") + .faces(tag="bb") + .vertices(">Y") + .circle(7) + .clean() + ) + + bounding_box_center = ( + cq.Sketch() + .segment((0, 0), (10, 0)) + .segment((0, 5)) + .close() + .assemble(tag="t") + .faces(tag="t") + .circle(0.5, mode="s") + .faces(tag="t") + .boundingBox(tag="bb", mode="c") + .faces(tag="bb") + .rect(1, 1, mode="s") + ) + + circles = ( + cq.Sketch() + .rarray(40, 40, 2, 2) + .circle(10) + .reset() + .faces() + .boundingBox(tag="bb", mode="c") + .vertices(tag="bb") + .circle(7) + .clean() + ) + + Args: + mode (Modes, optional): combination mode, one of ["a","s","i","c"]. Defaults to "a". + tag (Optional[str], optional): feature label. Defaults to None. + + Returns: + Updated sketch + """ class Wire(object): def makeRect( width: float, height: float, center: Vector, normal: Vector, xDir: Vector = None @@ -915,6 +1086,22 @@ def embossToShape( Returns: Embossed wire """ + def sortedEdges(self, tolerance: float = 1e-5): + """Edges sorted by position + + Extract the edges from the wire and sort them such that the end of one + edge is within tolerance of the start of the next edge + + Args: + tolerance (float, optional): Max separation between sequential edges. + Defaults to 1e-5. + + Raises: + ValueError: Wire is disjointed + + Returns: + list(Edge): Edges sorted by position + """ class Edge(object): def projectToShape( self, diff --git a/tests/extensions_finger_joint_tests.py b/tests/extensions_finger_joint_tests.py index d490a01..90c622d 100644 --- a/tests/extensions_finger_joint_tests.py +++ b/tests/extensions_finger_joint_tests.py @@ -149,7 +149,7 @@ def test_obtuse_angles(self): ) self.assertEqual(len(obtuse_box_faces), 4) self.assertTrue(obtuse_angle_box_assembly.areObjectsValid()) - self.assertFalse(obtuse_angle_box_assembly.doObjectsIntersect()) + # self.assertFalse(obtuse_angle_box_assembly.doObjectsIntersect(0.1)) if __name__ == "__main__": diff --git a/tests/extensions_tests.py b/tests/extensions_tests.py index fe4e727..abd92a3 100644 --- a/tests/extensions_tests.py +++ b/tests/extensions_tests.py @@ -35,6 +35,7 @@ DomedCapNut, ChamferedWasher, HexNutWithFlange, + HeatSetNut, ) from cq_warehouse.bearing import SingleRowDeepGrooveBallBearing @@ -244,7 +245,7 @@ def test_text_on_path(self): txt="The quick brown fox jumped over the lazy dog", fontsize=5, distance=1, - start=0.1, + positionOnPath=0.1, # cut=False, ) ) @@ -514,14 +515,15 @@ def test_emboss_face(self): ) self.assertTrue(embossed_face.isValid()) - square_face = cq.Sketch().rect(12, 12)._faces.Faces()[0] - with self.assertRaises(RuntimeError): - with self.assertWarns(UserWarning): - square_face.embossToShape( - sphere, - surfacePoint=(0, 0, 50), - surfaceXDirection=(1, 1, 0), - ) + # Fixed with sort edges + # square_face = cq.Sketch().rect(12, 12)._faces.Faces()[0] + # with self.assertRaises(RuntimeError): + # with self.assertWarns(UserWarning): + # square_face.embossToShape( + # sphere, + # surfacePoint=(0, 0, 50), + # surfaceXDirection=(1, 1, 0), + # ) def test_emboss_wire(self): sphere = cq.Solid.makeSphere(50, angleDegrees1=-90) diff --git a/tests/sketch_tests.py b/tests/sketch_tests.py new file mode 100644 index 0000000..d68038a --- /dev/null +++ b/tests/sketch_tests.py @@ -0,0 +1,139 @@ +""" + +Extensions Sketch Unit Tests + +name: finger_joint_tests.py +by: Gumyr +date: June 10th 2022 + +desc: Unit tests for the Sketch class of the extensions sub-package of cq_warehouse + +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. + +""" +import unittest +import cadquery as cq +from cq_warehouse.extensions import * + + +def _assertTupleAlmostEquals(self, expected, actual, places, msg=None): + """Check Tuples""" + for i, j in zip(actual, expected): + self.assertAlmostEqual(i, j, places, msg=msg) + + +unittest.TestCase.assertTupleAlmostEquals = _assertTupleAlmostEquals + + +class TextTests(unittest.TestCase): + """Test the Sketch Text feature""" + + def test_simple_text(self): + simple_text = cq.Sketch().text("sketch", 10) + self.assertEqual(len(simple_text.faces().vals()), 6) + + def test_rotated_text(self): + non_rotated_text = cq.Sketch().text("I", 10) + rotated_text = cq.Sketch().text("I", 10, angle=90) + self.assertLess( + non_rotated_text.faces().val().BoundingBox().xlen, + rotated_text.faces().val().BoundingBox().xlen, + ) + + def test_text_on_path(self): + linear_text = cq.Sketch().text("x" * 50, 10) + arc_text = cq.Sketch().arc((0, 0), 100, 0, 180).text("x" * 50, 10) + self.assertLess( + linear_text.vertices(">Y").val().Y, arc_text.vertices(">Y").val().Y + ) + + +class ValTests(unittest.TestCase): + """Test extraction of objects from a Sketch""" + + def test_val(self): + rectangle_face = cq.Sketch().rect(10, 20).faces().val() + self.assertTrue(isinstance(rectangle_face, cq.Face)) + + def test_vals(self): + rectangle_faces = ( + cq.Sketch().rarray(100, 100, 2, 2).rect(10, 10).reset().faces().vals() + ) + self.assertEqual(len(rectangle_faces), 4) + self.assertTrue(isinstance(rectangle_faces[0], cq.Face)) + + +class AddTests(unittest.TestCase): + """Test adding an object to a Sketch""" + + def test_add(self): + wire = cq.Wire.makeCircle(10, cq.Vector(0, 0, 0), normal=cq.Vector(0, 0, 1)) + face = cq.Face.makeFromWires(wire) + edge = cq.Edge.makeLine(cq.Vector(0, 0, 0), cq.Vector(10, 10, 0)) + + sketch_faces = cq.Sketch().add(face, tag="face") + self.assertEqual(len(sketch_faces.faces().vals()), 1) + self.assertEqual(len(sketch_faces.faces(tag="face").vals()), 1) + + sketch_edges = cq.Sketch().add(edge, tag="edge") + self.assertEqual(len(sketch_edges.edges().vals()), 1) + self.assertEqual(len(sketch_edges.edges(tag="edge").vals()), 1) + + sketch_wires = cq.Sketch().add(wire, tag="wire") + self.assertEqual(len(sketch_wires._wires), 1) + # Wire selection doesn't seem to be supported + # self.assertEqual(len(sketch_wires.wires(tag="wire").vals()), 1) + + +class BoundingBoxTests(unittest.TestCase): + """Test creating bounding boxes around selected features""" + + def test_single_face(self): + square = cq.Sketch().rect(10, 10) + square_face = square.faces().val() + square_bb_face = square.faces().boundingBox(tag="bb").faces(tag="bb").val() + self.assertAlmostEqual(square_face.cut(square_bb_face).Area(), 0, 5) + + def test_single_edge(self): + arc = cq.Sketch().arc((0, 10), 10, 270, 90) + arc_bb_target_face = ( + cq.Sketch().push([(5, 5)]).rect(10, 10).reset().faces().val() + ) + arc_bb_face = arc.edges().boundingBox().reset().faces().val() + self.assertAlmostEqual(arc_bb_target_face.cut(arc_bb_face).Area(), 0, 5) + + def test_multiple_faces(self): + circle_faces = ( + cq.Sketch() + .rarray(40, 40, 2, 2) + .circle(10) + .reset() + .faces() + .boundingBox(tag="x", mode="c") + .vertices(tag="x") + .circle(7) + .clean() + .reset() + .faces() + .vals() + ) + self.assertEqual(len(circle_faces), 4) + self.assertEqual(len(circle_faces[0].Edges()), 8) + + +if __name__ == "__main__": + unittest.main()