diff --git a/mypy.ini b/mypy.ini index 947693e4..2fb7f4b3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -8,7 +8,7 @@ ignore_missing_imports = True [mypy-build123d.topology.jupyter_tools.*] ignore_missing_imports = True -[mypy-IPython.lib.pretty.*] +[mypy-IPython.*] ignore_missing_imports = True [mypy-numpy.*] @@ -28,3 +28,12 @@ ignore_missing_imports = True [mypy-vtkmodules.*] ignore_missing_imports = True + +[mypy-ezdxf.*] +ignore_missing_imports = True + +[mypy-setuptools_scm.*] +ignore_missing_imports = True + +[mypy-py_lib3mf.*] +ignore_missing_imports = True diff --git a/src/build123d/build_common.py b/src/build123d/build_common.py index f5fef9b7..92a785ea 100644 --- a/src/build123d/build_common.py +++ b/src/build123d/build_common.py @@ -243,7 +243,7 @@ def __init__( self.builder_parent = None self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []} self.workplanes_context = None - self.exit_workplanes = None + self.exit_workplanes: list[Plane] = [] self.obj_before: Shape | None = None self.to_combine: list[Shape] = [] diff --git a/src/build123d/drafting.py b/src/build123d/drafting.py index e2929e10..2c995abe 100644 --- a/src/build123d/drafting.py +++ b/src/build123d/drafting.py @@ -29,7 +29,7 @@ from dataclasses import dataclass from datetime import date from math import copysign, floor, gcd, log2, pi -from typing import ClassVar, Optional, Union +from typing import cast, ClassVar, TypeAlias from collections.abc import Iterable @@ -102,7 +102,7 @@ class Arrow(BaseSketchObject): Args: arrow_size (float): arrow head tip to tail length - shaft_path (Union[Edge, Wire]): line describing the shaft shape + shaft_path (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. @@ -141,17 +141,15 @@ def __init__( shaft_pen = shaft_path.perpendicular_line(shaft_width, 0) shaft = sweep(shaft_pen, shaft_path, mode=Mode.PRIVATE) - arrow = arrow_head.fuse(shaft).clean() + arrow = cast(Compound, 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]] +PointLike: TypeAlias = Vector | Vertex | tuple[float, float, float] +"""General type for points in 3D space""" +PathDescriptor: TypeAlias = Wire | Edge | list[PointLike] +"""General type for a path in 3D space""" @dataclass @@ -223,7 +221,7 @@ def _round_to_str(self, number: float) -> str: def _number_with_units( self, number: float, - tolerance: float | tuple[float, float] = None, + tolerance: float | tuple[float, float] | None = None, display_units: bool | None = None, ) -> str: """Convert a raw number to a unit of measurement string based on the class settings""" @@ -295,7 +293,7 @@ def _process_path(path: PathDescriptor) -> Edge | Wire: def _label_to_str( self, - label: str, + label: str | None, line_wire: Wire, label_angle: bool, tolerance: float | tuple[float, float] | None, @@ -351,7 +349,7 @@ class DimensionLine(BaseSketchObject): 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 + tolerance (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. @@ -368,14 +366,14 @@ class DimensionLine(BaseSketchObject): def __init__( self, path: PathDescriptor, - draft: Draft = None, - sketch: Sketch = None, - label: str = None, + draft: Draft, + sketch: Sketch | None = None, + label: str | None = None, arrows: tuple[bool, bool] = (True, True), - tolerance: float | tuple[float, float] = None, + tolerance: float | tuple[float, float] | None = None, label_angle: bool = False, mode: Mode = Mode.ADD, - ) -> Sketch: + ): # pylint: disable=too-many-locals context = BuildSketch._get_context(self) @@ -452,22 +450,35 @@ def __init__( 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 = Sketch.intersect(d_line, placed_label).area + self_intersection = cast( + Sketch | None, Sketch.intersect(d_line, placed_label) + ) + if self_intersection is None: + self_intersection_area = 0.0 + else: + self_intersection_area = self_intersection.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 Sketch.intersect(d_line, sketch).area - ) - common_area += self_intersection + if sketch is None: + common_area = 0.0 + else: + line_intersection = cast( + Sketch | None, Sketch.intersect(d_line, sketch) + ) + if line_intersection is None: + common_area = 0.0 + else: + common_area = line_intersection.area + common_area += self_intersection_area 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]) + sorted_d_lines = sorted(d_lines.items(), key=lambda x: x[1]) - super().__init__(obj=d_lines[-1][0], rotation=0, align=None, mode=mode) + super().__init__(obj=sorted_d_lines[-1][0], rotation=0, align=None, mode=mode) class ExtensionLine(BaseSketchObject): @@ -489,7 +500,7 @@ class ExtensionLine(BaseSketchObject): 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 + tolerance (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. @@ -507,12 +518,12 @@ def __init__( border: PathDescriptor, offset: float, draft: Draft, - sketch: Sketch = None, - label: str = None, + sketch: Sketch | None = None, + label: str | None = None, arrows: tuple[bool, bool] = (True, True), - tolerance: float | tuple[float, float] = None, + tolerance: float | tuple[float, float] | None = None, label_angle: bool = False, - project_line: VectorLike = None, + project_line: VectorLike | None = None, mode: Mode = Mode.ADD, ): # pylint: disable=too-many-locals @@ -531,7 +542,7 @@ def __init__( 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 + distance=offset, side=side_lut[int(copysign(1, offset))], closed=False ) dimension_label_str = ( label @@ -629,7 +640,7 @@ def __init__( title: str = "Title", sub_title: str = "Sub Title", drawing_number: str = "B3D-1", - sheet_number: int = None, + sheet_number: int | None = None, drawing_scale: float = 1.0, nominal_text_size: float = 10.0, line_width: float = 0.5, @@ -691,12 +702,12 @@ def __init__( 4: 3 / 12, 5: 5 / 12, } - for i, label in enumerate(["F", "E", "D", "C", "B", "A"]): + for i, grid_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) + ) * Sketch(Compound.make_text(grid_label, nominal_text_size).wrapped) # Text Box Frame bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5 diff --git a/src/build123d/exporters.py b/src/build123d/exporters.py index 9d0ba2e2..810d9c91 100644 --- a/src/build123d/exporters.py +++ b/src/build123d/exporters.py @@ -34,9 +34,9 @@ import xml.etree.ElementTree as ET from copy import copy from enum import Enum, auto -from os import PathLike, fsdecode, fspath -from pathlib import Path -from typing import List, Optional, Tuple, Union +from os import PathLike, fsdecode +from typing import Any, TypeAlias +from warnings import warn from collections.abc import Callable, Iterable @@ -45,16 +45,15 @@ from ezdxf import zoom from ezdxf.colors import RGB, aci2rgb from ezdxf.math import Vec2 -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 OCP.BRepLib import BRepLib +from OCP.Geom import Geom_BezierCurve +from OCP.GeomConvert import GeomConvert +from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve +from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ +from OCP.HLRAlgo import HLRAlgo_Projector +from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape +from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum +from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS from typing_extensions import Self @@ -69,7 +68,8 @@ ) from build123d.build_common import UNITS_PER_METER -PathSegment = Union[PT.Line, PT.Arc, PT.QuadraticBezier, PT.CubicBezier] +PathSegment: TypeAlias = PT.Line | PT.Arc | PT.QuadraticBezier | PT.CubicBezier +"""A type alias for the various path segment types in the svgpathtools library.""" # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- @@ -82,7 +82,7 @@ def __init__( self, shape: Shape, *, - look_at: VectorLike = None, + look_at: VectorLike | None = None, look_from: VectorLike = (1, -1, 1), look_up: VectorLike = (0, 0, 1), with_hidden: bool = True, @@ -562,7 +562,7 @@ def add_layer( """ # ezdxf :doc:`line type `. - kwargs = {} + kwargs: dict[str, Any] = {} if line_type is not None: linetype = self._linetype(line_type) @@ -587,7 +587,7 @@ def _linetype(self, line_type: LineType) -> str: # The linetype is not in the doc yet. # Add it from our available definitions. if linetype in Export2D.LINETYPE_DEFS: - desc, pattern = Export2D.LINETYPE_DEFS.get(linetype) + desc, pattern = Export2D.LINETYPE_DEFS.get(linetype) # type: ignore[misc] self._document.linetypes.add( name=linetype, pattern=[self._linetype_scale * v for v in pattern], @@ -605,7 +605,7 @@ def add_shape(self, shape: Shape | Iterable[Shape], layer: str = "") -> Self: Adds a shape to the specified layer. Args: - shape (Union[Shape, Iterable[Shape]]): The shape or collection of shapes to be + shape (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 "". @@ -641,8 +641,8 @@ def write(self, file_name: PathLike | str | bytes): Writes the DXF data to the specified file name. Args: - file_name (Union[PathLike, str, bytes]): The file name (including path) where the DXF data will - be written. + file_name (PathLike | str | bytes): 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. @@ -757,6 +757,8 @@ def _convert_bspline(self, edge: Edge, attribs): ) # need to apply the transform on the geometry level + if edge.wrapped is None or edge.location is None: + raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) @@ -828,17 +830,17 @@ class ExportSVG(Export2D): 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 + fill_color (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 + line_color (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 + dot_length (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. @@ -878,21 +880,28 @@ def __init__( line_type: LineType, ): def convert_color( - c: ColorIndex | RGB | Color | None, + input_color: ColorIndex | RGB | Color | None, ) -> Color | None: - if isinstance(c, ColorIndex): + if isinstance(input_color, ColorIndex): # 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 c == ColorIndex.BLACK: - c = RGB(0, 0, 0) + if input_color == ColorIndex.BLACK: + rgb_color = RGB(0, 0, 0) else: - c = aci2rgb(c.value) - elif isinstance(c, tuple): - c = RGB(*c) - if isinstance(c, RGB): - c = Color(*c.to_floats(), 1) - return c + rgb_color = aci2rgb(input_color.value) + elif isinstance(input_color, tuple): + rgb_color = RGB(*input_color) + else: + rgb_color = input_color # If not ColorIndex or tuple, it's already RGB or None + + if isinstance(rgb_color, RGB): + red, green, blue = rgb_color.to_floats() + final_color = Color(red, green, blue, 1.0) + else: + final_color = rgb_color # If not RGB, it's None or already a Color + + return final_color self.name = name self.fill_color = convert_color(fill_color) @@ -929,7 +938,7 @@ def __init__( self.dot_length = dot_length self._non_planar_point_count = 0 self._layers: dict[str, ExportSVG._Layer] = {} - self._bounds: BoundBox = None + self._bounds: BoundBox | None = None # Add the default layer. self.add_layer( @@ -957,10 +966,10 @@ def add_layer( Args: name (str): The name of the layer. Must be unique among all layers. - fill_color (Union[ColorIndex, RGB, Color, None], optional): The fill color for shapes + fill_color (ColorIndex | RGB | Color | None, optional): The fill color for shapes on this layer. It can be specified as a ColorIndex, an RGB tuple, a Color, or None. Defaults to None. - line_color (Union[ColorIndex, RGB, Color, None], optional): The line color for shapes on + line_color (ColorIndex | RGB | Color | None, optional): The line color for shapes on this layer. It can be specified as a ColorIndex or an RGB tuple, a Color, or None. Defaults to Export2D.DEFAULT_COLOR_INDEX. line_weight (float, optional): The line weight (stroke width) for shapes on @@ -1002,7 +1011,7 @@ def 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 + shape (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 "". @@ -1014,12 +1023,12 @@ def add_shape( """ if layer not in self._layers: raise ValueError(f"Undefined layer: {layer}.") - layer = self._layers[layer] + _layer = self._layers[layer] if isinstance(shape, Shape): - self._add_single_shape(shape, layer, reverse_wires) + self._add_single_shape(shape, _layer, reverse_wires) else: for s in shape: - self._add_single_shape(s, layer, reverse_wires) + self._add_single_shape(s, _layer, reverse_wires) def _add_single_shape(self, shape: Shape, layer: _Layer, reverse_wires: bool): # pylint: disable=too-many-locals @@ -1188,6 +1197,12 @@ def _line_element(self, edge: Edge) -> ET.Element: def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: # pylint: disable=too-many-locals + if edge.length < 1e-6: + warn( + "Skipping arc that is too small to export safely (length < 1e-6).", + stacklevel=7, + ) + return [] curve = edge.geom_adaptor() circle = curve.Circle() radius = circle.Radius() @@ -1234,6 +1249,12 @@ def _circle_element(self, edge: Edge) -> ET.Element: def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: # pylint: disable=too-many-locals + if edge.length < 1e-6: + warn( + "Skipping ellipse that is too small to export safely (length < 1e-6).", + stacklevel=7, + ) + return [] curve = edge.geom_adaptor() ellipse = curve.Ellipse() minor_radius = ellipse.MinorRadius() @@ -1283,6 +1304,8 @@ def _bspline_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: u2 = adaptor.LastParameter() # Apply the shape location to the geometry. + if edge.wrapped is None or edge.location is None: + raise ValueError(f"Edge is empty {edge}.") t = edge.location.wrapped.Transformation() spline.Transform(t) # describe_bspline(spline) @@ -1347,6 +1370,8 @@ def _other_element(self, edge: Edge) -> ET.Element: } def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: + if edge.wrapped is None: + raise ValueError(f"Edge is empty {edge}.") edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED geom_type = edge.geom_type segments = self._SEGMENT_LOOKUP.get(geom_type, ExportSVG._other_segments) @@ -1391,10 +1416,12 @@ def _stroke_dasharray(self, layer: _Layer): # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def _group_for_layer(self, layer: _Layer, attribs: dict = None) -> ET.Element: - def _color_attribs(c: Color) -> tuple[str, str]: - if c: - (r, g, b, a) = tuple(c) + def _group_for_layer( + self, layer: _Layer, attribs: dict | None = None + ) -> ET.Element: + def _color_attribs(color: Color | None) -> tuple[str, str | None]: + if color is not None: + (r, g, b, a) = tuple(color) (r, g, b, a) = (int(r * 255), int(g * 255), int(b * 255), round(a, 3)) rgb = f"rgb({r},{g},{b})" opacity = f"{a}" if a < 1 else None @@ -1403,9 +1430,9 @@ def _color_attribs(c: Color) -> tuple[str, str]: if attribs is None: attribs = {} - (fill, fill_opacity) = _color_attribs(layer.fill_color) + fill, fill_opacity = _color_attribs(layer.fill_color) attribs["fill"] = fill - if fill_opacity: + if fill_opacity is not None: attribs["fill-opacity"] = fill_opacity (stroke, stroke_opacity) = _color_attribs(layer.line_color) attribs["stroke"] = stroke @@ -1435,10 +1462,12 @@ def write(self, path: PathLike | str | bytes): Writes the SVG data to the specified file path. Args: - path (Union[PathLike, str, bytes]): The file path where the SVG data will be written. + path (PathLike | str | bytes): The file path where the SVG data will be written. """ # pylint: disable=too-many-locals bb = self._bounds + if bb is None: + raise ValueError("No shapes to export.") doc_margin = self.margin if self.fit_to_stroke: max_line_weight = max(l.line_weight for l in self._layers.values()) @@ -1479,4 +1508,5 @@ def write(self, path: PathLike | str | bytes): xml = ET.ElementTree(svg) ET.indent(xml, " ") - xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=False) + # xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=False) + xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=None) diff --git a/src/build123d/objects_part.py b/src/build123d/objects_part.py index cce0175f..9a3da0e9 100644 --- a/src/build123d/objects_part.py +++ b/src/build123d/objects_part.py @@ -30,12 +30,11 @@ from math import radians, tan -from typing import Union from build123d.build_common import LocationList, validate_inputs from build123d.build_enums import Align, Mode from build123d.build_part import BuildPart -from build123d.geometry import Location, Plane, Rotation, RotationLike, Vector -from build123d.topology import Compound, Part, Solid, tuplify +from build123d.geometry import Location, Plane, Rotation, RotationLike +from build123d.topology import Compound, Part, ShapeList, Solid, tuplify class BasePartObject(Part): @@ -46,7 +45,7 @@ class BasePartObject(Part): Args: solid (Solid): object to create rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to None. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ @@ -57,7 +56,7 @@ def __init__( self, part: Part | Solid, rotation: RotationLike = (0, 0, 0), - align: Align | tuple[Align, Align, Align] = None, + align: Align | tuple[Align, Align, Align] | None = None, mode: Mode = Mode.ADD, ): if align is not None: @@ -66,7 +65,7 @@ def __init__( offset = bbox.to_align_offset(align) part.move(Location(offset)) - context: BuildPart = BuildPart._get_context(self, log=False) + context: BuildPart | None = BuildPart._get_context(self, log=False) rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation self.rotation = rotate if context is None: @@ -111,7 +110,7 @@ class Box(BasePartObject): width (float): box size height (float): box size rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -131,7 +130,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.length = length @@ -156,7 +155,7 @@ class Cone(BasePartObject): height (float): cone size arc_size (float, optional): angular size of cone. Defaults to 360. rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -177,7 +176,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.bottom_radius = bottom_radius @@ -218,10 +217,10 @@ def __init__( radius: float, counter_bore_radius: float, counter_bore_depth: float, - depth: float = None, + depth: float | None = None, mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -235,7 +234,7 @@ def __init__( raise ValueError("No depth provided") self.mode = mode - solid = Solid.make_cylinder( + fused = Solid.make_cylinder( radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1)) ).fuse( Solid.make_cylinder( @@ -244,6 +243,10 @@ def __init__( Plane((0, 0, -counter_bore_depth)), ) ) + if isinstance(fused, ShapeList): + solid = Part(fused) + else: + solid = fused super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) @@ -266,11 +269,11 @@ def __init__( self, radius: float, counter_sink_radius: float, - depth: float = None, + depth: float | None = None, counter_sink_angle: float = 82, # Common tip angle mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -285,7 +288,7 @@ def __init__( self.mode = mode cone_height = counter_sink_radius / tan(radians(counter_sink_angle / 2.0)) - solid = Solid.make_cylinder( + fused = Solid.make_cylinder( radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1)) ).fuse( Solid.make_cone( @@ -296,6 +299,11 @@ def __init__( ), Solid.make_cylinder(counter_sink_radius, self.hole_depth), ) + if isinstance(fused, ShapeList): + solid = Part(fused) + else: + solid = fused + super().__init__(part=solid, rotation=(0, 0, 0), mode=mode) @@ -309,7 +317,7 @@ class Cylinder(BasePartObject): height (float): cylinder size arc_size (float, optional): angular size of cone. Defaults to 360. rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -329,7 +337,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -363,10 +371,10 @@ class Hole(BasePartObject): def __init__( self, radius: float, - depth: float = None, + depth: float | None = None, mode: Mode = Mode.SUBTRACT, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -405,7 +413,7 @@ class Sphere(BasePartObject): arc_size2 (float, optional): angular size of sphere. Defaults to 90. arc_size3 (float, optional): angular size of sphere. Defaults to 360. rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -426,7 +434,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.radius = radius @@ -458,7 +466,7 @@ class Torus(BasePartObject): major_arc_size (float, optional): angular size of torus. Defaults to 0. minor_arc_size (float, optional): angular size or torus. Defaults to 360. rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -480,7 +488,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) self.major_radius = major_radius @@ -516,7 +524,7 @@ class Wedge(BasePartObject): xmax (float): maximum X location zmax (float): maximum Z location rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). - align (Union[Align, tuple[Align, Align, Align]], optional): align min, center, + align (Align | tuple[Align, Align, Align] | None, optional): align min, center, or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER). mode (Mode, optional): combine mode. Defaults to Mode.ADD. """ @@ -540,7 +548,7 @@ def __init__( ), mode: Mode = Mode.ADD, ): - context: BuildPart = BuildPart._get_context(self) + context: BuildPart | None = BuildPart._get_context(self) validate_inputs(context, self) if any([value <= 0 for value in [xsize, ysize, zsize]]): diff --git a/src/build123d/objects_sketch.py b/src/build123d/objects_sketch.py index a6689dae..65771d23 100644 --- a/src/build123d/objects_sketch.py +++ b/src/build123d/objects_sketch.py @@ -31,7 +31,7 @@ import trianglesolver from math import cos, degrees, pi, radians, sin, tan -from typing import Union +from typing import cast from collections.abc import Iterable @@ -85,7 +85,7 @@ def __init__( align = tuplify(align, 2) obj.move(Location(obj.bounding_box().to_align_offset(align))) - context: BuildSketch = BuildSketch._get_context(self, log=False) + context: BuildSketch | None = BuildSketch._get_context(self, log=False) if context is None: new_faces = obj.moved(Rotation(0, 0, rotation)).faces() @@ -95,11 +95,11 @@ def __init__( obj = obj.moved(Rotation(0, 0, rotation)) - new_faces = [ + new_faces = ShapeList( face.moved(location) for face in obj.faces() for location in LocationList._get_context().local_locations - ] + ) if isinstance(context, BuildSketch): context._add_to_context(*new_faces, mode=mode) @@ -126,7 +126,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.radius = radius @@ -160,7 +160,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.x_radius = x_radius @@ -199,7 +199,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) flattened_pts = flatten_sequence(*pts) @@ -235,7 +235,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.width = width @@ -272,7 +272,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if width <= 2 * radius or height <= 2 * radius: @@ -317,7 +317,7 @@ def __init__( mode: Mode = Mode.ADD, ): # pylint: disable=too-many-locals - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if side_count < 3: @@ -381,7 +381,7 @@ def __init__( rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.arc = arc @@ -417,7 +417,7 @@ def __init__( rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) center_v = Vector(center) @@ -472,7 +472,7 @@ def __init__( f"Requires center_separation > 0. Got: {center_separation=}" ) - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.center_separation = center_separation @@ -518,14 +518,14 @@ def __init__( f"Slot requires that width > height. Got: {width=}, {height=}" ) - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.width = width self.slot_height = height if width != height: - face: Face | None = Face( + face = Face( Wire( [ Edge.make_line(Vector(-width / 2 + height / 2, 0, 0), Vector()), @@ -534,7 +534,7 @@ def __init__( ).offset_2d(height / 2) ) else: - face = Circle(width / 2, mode=mode).face() + face = cast(Face, Circle(width / 2, mode=mode).face()) super().__init__(face, rotation, align, mode) @@ -574,7 +574,7 @@ def __init__( rotation: float = 0.0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) self.txt = txt @@ -633,7 +633,7 @@ def __init__( align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) right_side_angle = left_side_angle if not right_side_angle else right_side_angle @@ -720,7 +720,7 @@ def __init__( rotation: float = 0, mode: Mode = Mode.ADD, ): - context = BuildSketch._get_context(self) + context: BuildSketch | None = BuildSketch._get_context(self) validate_inputs(context, self) if [v is None for v in [a, b, c]].count(True) == 3 or [ diff --git a/src/build123d/operations_generic.py b/src/build123d/operations_generic.py index 67370c58..2deeeaa8 100644 --- a/src/build123d/operations_generic.py +++ b/src/build123d/operations_generic.py @@ -30,7 +30,7 @@ import copy import logging from math import radians, tan -from typing import Union +from typing import cast, TypeAlias from collections.abc import Iterable @@ -78,13 +78,13 @@ logging.getLogger("build123d").addHandler(logging.NullHandler()) logger = logging.getLogger("build123d") -#:TypeVar("AddType"): Type of objects which can be added to a builder -AddType = Union[Edge, Wire, Face, Solid, Compound, Builder] +AddType: TypeAlias = Edge | Wire | Face | Solid | Compound | Builder +"""Type of objects which can be added to a builder""" def add( objects: AddType | Iterable[AddType], - rotation: float | RotationLike = None, + rotation: float | RotationLike | None = None, clean: bool = True, mode: Mode = Mode.ADD, ) -> Compound: @@ -101,22 +101,28 @@ def add( Edges and Wires are added to line. Args: - objects (Union[Edge, Wire, Face, Solid, Compound] or Iterable of): objects to add - rotation (Union[float, RotationLike], optional): rotation angle for sketch, + objects (Edge | Wire | Face | Solid | Compound or Iterable of): objects to add + rotation (float | RotationLike, optional): rotation angle for sketch, rotation about each axis for part. Defaults to None. 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) + context: Builder | None = 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] + if isinstance(objects, Iterable) and not isinstance(objects, Compound): + object_list = list(objects) + else: + object_list = [objects] object_iter = [ - obj.unwrap(fully=False) if isinstance(obj, Compound) else obj - for obj in object_iter + ( + obj.unwrap(fully=False) + if isinstance(obj, Compound) + else obj._obj if isinstance(obj, Builder) else obj + ) + for obj in object_list ] - object_iter = [obj._obj if isinstance(obj, Builder) else obj for obj in object_iter] validate_inputs(context, "add", object_iter) @@ -201,7 +207,7 @@ def add( def bounding_box( - objects: Shape | Iterable[Shape] = None, + objects: Shape | Iterable[Shape] | None = None, mode: Mode = Mode.PRIVATE, ) -> Sketch | Part: """Generic Operation: Add Bounding Box @@ -214,7 +220,7 @@ def bounding_box( objects (Shape or Iterable of): objects to create bbox for mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: Builder = Builder._get_context("bounding_box") + context: Builder | None = Builder._get_context("bounding_box") if objects is None: if context is None or context is not None and context._obj is None: @@ -261,16 +267,16 @@ def bounding_box( return Part(Compound(new_objects).wrapped) -#:TypeVar("ChamferFilletType"): Type of objects which can be chamfered or filleted -ChamferFilletType = Union[Edge, Vertex] +ChamferFilletType: TypeAlias = Edge | Vertex +"""Type of objects which can be chamfered or filleted""" def chamfer( objects: ChamferFilletType | Iterable[ChamferFilletType], length: float, - length2: float = None, - angle: float = None, - reference: Edge | Face = None, + length2: float | None = None, + angle: float | None = None, + reference: Edge | Face | None = None, ) -> Sketch | Part: """Generic Operation: chamfer @@ -279,11 +285,11 @@ def chamfer( Chamfer the given sequence of edges or vertices. Args: - objects (Union[Edge,Vertex] or Iterable of): edges or vertices to chamfer + objects (Edge | Vertex or Iterable of): edges or vertices to chamfer length (float): chamfer size length2 (float, optional): asymmetric chamfer size. Defaults to None. angle (float, optional): chamfer angle in degrees. Defaults to None. - reference (Union[Edge,Face]): identifies the side where length is measured. Edge(s) must + reference (Edge | Face): identifies the side where length is measured. Edge(s) must be part of the face. Vertex/Vertices must be part of edge Raises: @@ -293,7 +299,7 @@ def chamfer( ValueError: Only one of length2 or angle should be provided ValueError: reference can only be used in conjunction with length2 or angle """ - context: Builder = Builder._get_context("chamfer") + context: Builder | None = Builder._get_context("chamfer") if length2 and angle: raise ValueError("Only one of length2 or angle should be provided") @@ -367,24 +373,28 @@ def chamfer( 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 ( - isclose_b( - (Vector(*v.to_tuple()) - target.position_at(0)).length, - 0.0, - ) - or isclose_b( - (Vector(*v.to_tuple()) - target.position_at(1)).length, - 0.0, - ) - ), - object_list, + object_list = ShapeList( + filter( + lambda v: not ( + isclose_b( + (Vector(*v.to_tuple()) - target.position_at(0)).length, + 0.0, + ) + or isclose_b( + (Vector(*v.to_tuple()) - target.position_at(1)).length, + 0.0, + ) + ), + object_list, + ) ) new_wire = target.chamfer_2d(length, length2, object_list, reference) if context is not None: context._add_to_context(new_wire, mode=Mode.REPLACE) return new_wire + raise ValueError("Invalid object dimension") + def fillet( objects: ChamferFilletType | Iterable[ChamferFilletType], @@ -398,7 +408,7 @@ def fillet( either end of an open line will be automatically skipped. Args: - objects (Union[Edge,Vertex] or Iterable of): edges or vertices to fillet + objects (Edge | Vertex or Iterable of): edges or vertices to fillet radius (float): fillet size - must be less than 1/2 local width Raises: @@ -407,7 +417,7 @@ def fillet( ValueError: objects must be Vertices ValueError: nothing to fillet """ - context: Builder = Builder._get_context("fillet") + context: Builder | None = Builder._get_context("fillet") if (objects is None and context is None) or ( objects is None and context is not None and context._obj is None ): @@ -466,31 +476,35 @@ def fillet( 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 ( - isclose_b( - (Vector(*v.to_tuple()) - target.position_at(0)).length, - 0.0, - ) - or isclose_b( - (Vector(*v.to_tuple()) - target.position_at(1)).length, - 0.0, - ) - ), - object_list, + object_list = ShapeList( + filter( + lambda v: not ( + isclose_b( + (Vector(*v.to_tuple()) - target.position_at(0)).length, + 0.0, + ) + or isclose_b( + (Vector(*v.to_tuple()) - target.position_at(1)).length, + 0.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 + raise ValueError("Invalid object dimension") -#:TypeVar("MirrorType"): Type of objects which can be mirrored -MirrorType = Union[Edge, Wire, Face, Compound, Curve, Sketch, Part] + +MirrorType: TypeAlias = Edge | Wire | Face | Compound | Curve | Sketch | Part +"""Type of objects which can be mirrored""" def mirror( - objects: MirrorType | Iterable[MirrorType] = None, + objects: MirrorType | Iterable[MirrorType] | None = None, about: Plane = Plane.XZ, mode: Mode = Mode.ADD, ) -> Curve | Sketch | Part | Compound: @@ -501,15 +515,18 @@ def mirror( Mirror a sequence of objects over the given plane. Args: - objects (Union[Edge, Face,Compound] or Iterable of): objects to mirror + objects (Edge | Face | Compound or Iterable of): objects to mirror about (Plane, optional): reference plane. Defaults to "XZ". mode (Mode, optional): combination mode. Defaults to Mode.ADD. Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("mirror") - object_list = objects if isinstance(objects, Iterable) else [objects] + context: Builder | None = Builder._get_context("mirror") + if isinstance(objects, Iterable) and not isinstance(objects, Compound): + object_list = list(objects) + else: + object_list = [objects] if objects is None: if context is None or context is not None and context._obj is None: @@ -535,18 +552,18 @@ def mirror( return mirrored_compound -#:TypeVar("OffsetType"): Type of objects which can be offset -OffsetType = Union[Edge, Face, Solid, Compound] +OffsetType: TypeAlias = Edge | Face | Solid | Compound +"""Type of objects which can be offset""" def offset( - objects: OffsetType | Iterable[OffsetType] = None, + objects: OffsetType | Iterable[OffsetType] | None = None, amount: float = 0, - openings: Face | list[Face] = None, + openings: Face | list[Face] | None = None, kind: Kind = Kind.ARC, side: Side = Side.BOTH, closed: bool = True, - min_edge_length: float = None, + min_edge_length: float | None = None, mode: Mode = Mode.REPLACE, ) -> Curve | Sketch | Part | Compound: """Generic Operation: offset @@ -559,7 +576,7 @@ def offset( a hollow box with no lid. Args: - objects (Union[Edge, Face, Solid, Compound] or Iterable of): objects to offset + objects (Edge | Face | Solid | Compound or Iterable of): objects to offset amount (float): positive values external, negative internal openings (list[Face], optional), sequence of faces to open in part. Defaults to None. @@ -575,7 +592,7 @@ def offset( ValueError: missing objects ValueError: Invalid object type """ - context: Builder = Builder._get_context("offset") + context: Builder | None = Builder._get_context("offset") if objects is None: if context is None or context is not None and context._obj is None: @@ -624,15 +641,19 @@ def offset( pass # inner wires may go beyond the outer wire so subtract faces new_face = Face(outer_wire) - if inner_wires: - inner_faces = [Face(w) for w in inner_wires] - new_face = new_face.cut(*inner_faces) - if isinstance(new_face, Compound): - new_face = new_face.unwrap(fully=True) - if (new_face.normal_at() - face.normal_at()).length > 0.001: new_face = -new_face - new_faces.append(new_face) + if inner_wires: + inner_faces = [Face(w) for w in inner_wires] + subtraction = new_face.cut(*inner_faces) + if isinstance(subtraction, Compound): + new_faces.append(new_face.unwrap(fully=True)) + elif isinstance(subtraction, ShapeList): + new_faces.extend(subtraction) + else: + new_faces.append(subtraction) + else: + new_faces.append(new_face) if edges: if len(edges) == 1 and edges[0].geom_type == GeomType.LINE: new_wires = [ @@ -679,14 +700,14 @@ def offset( return offset_compound -#:TypeVar("ProjectType"): Type of objects which can be projected -ProjectType = Union[Edge, Face, Wire, Vector, Vertex] +ProjectType: TypeAlias = Edge | Face | Wire | Vector | Vertex +"""Type of objects which can be projected""" def project( - objects: ProjectType | Iterable[ProjectType] = None, - workplane: Plane = None, - target: Solid | Compound | Part = None, + objects: ProjectType | Iterable[ProjectType] | None = None, + workplane: Plane | None = None, + target: Solid | Compound | Part | None = None, mode: Mode = Mode.ADD, ) -> Curve | Sketch | Compound | ShapeList[Vector]: """Generic Operation: project @@ -704,7 +725,7 @@ def project( BuildSketch and Edge/Wires into BuildLine. Args: - objects (Union[Edge, Face, Wire, VectorLike, Vertex] or Iterable of): + objects (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. @@ -716,7 +737,7 @@ def project( 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") + context: Builder | None = Builder._get_context("project") if isinstance(objects, GroupBy): raise ValueError("project doesn't accept group_by, did you miss [n]?") @@ -742,8 +763,8 @@ def project( ] object_size = Compound(children=shape_list).bounding_box(optimal=False).diagonal - point_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] - point_list = [Vector(pnt) for pnt in point_list] + vct_vrt_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] + point_list = [Vector(pnt) for pnt in vct_vrt_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))] @@ -764,6 +785,7 @@ def project( raise ValueError( "Edges, wires and points can only be projected in PRIVATE mode" ) + working_plane = cast(Plane, workplane) # BuildLine and BuildSketch are from target to workplane while BuildPart is # from workplane to target so the projection direction needs to be flipped @@ -775,7 +797,7 @@ def project( target = context._obj projection_flip = -1 else: - target = Face.make_rect(3 * object_size, 3 * object_size, plane=workplane) + target = Face.make_rect(3 * object_size, 3 * object_size, plane=working_plane) validate_inputs(context, "project") @@ -783,37 +805,39 @@ def project( obj: Shape for obj in face_list + line_list: obj_to_screen = (target.center() - obj.center()).normalized() - if workplane.from_local_coords(obj_to_screen).Z < 0: - projection_direction = -workplane.z_dir * projection_flip + if working_plane.from_local_coords(obj_to_screen).Z < 0: + projection_direction = -working_plane.z_dir * projection_flip else: - projection_direction = workplane.z_dir * projection_flip + projection_direction = working_plane.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] + [working_plane.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 = [] + projected_points: ShapeList[Vector] = ShapeList() for pnt in point_list: - pnt_to_target = (workplane.origin - pnt).normalized() - if workplane.from_local_coords(pnt_to_target).Z < 0: - projection_axis = -Axis(pnt, workplane.z_dir * projection_flip) + pnt_to_target = (working_plane.origin - pnt).normalized() + if working_plane.from_local_coords(pnt_to_target).Z < 0: + projection_axis = -Axis(pnt, working_plane.z_dir * projection_flip) else: - projection_axis = Axis(pnt, workplane.z_dir * projection_flip) - projection = workplane.to_local_coords(workplane.intersect(projection_axis)) - if projection is not None: - projected_points.append(projection) + projection_axis = Axis(pnt, working_plane.z_dir * projection_flip) + intersection = working_plane.intersect(projection_axis) + if isinstance(intersection, Axis): + raise RuntimeError("working_plane and projection_axis are parallel") + if intersection is not None: + projected_points.append(working_plane.to_local_coords(intersection)) if context is not None: context._add_to_context(*projected_shapes, mode=mode) if projected_points: - result = ShapeList(projected_points) + result = projected_points else: result = Compound(projected_shapes) if all([obj._dim == 2 for obj in object_list]): @@ -825,7 +849,7 @@ def project( def scale( - objects: Shape | Iterable[Shape] = None, + objects: Shape | Iterable[Shape] | None = None, by: float | tuple[float, float, float] = 1, mode: Mode = Mode.REPLACE, ) -> Curve | Sketch | Part | Compound: @@ -838,14 +862,14 @@ def scale( line, circle, etc. Args: - objects (Union[Edge, Face, Compound, Solid] or Iterable of): objects to scale - by (Union[float, tuple[float, float, float]]): scale factor + objects (Edge | Face | Compound | Solid or Iterable of): objects to scale + by (float | tuple[float, float, float]): scale factor mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("scale") + context: Builder | None = Builder._get_context("scale") if objects is None: if context is None or context is not None and context._obj is None: @@ -863,12 +887,12 @@ def scale( and len(by) == 3 and all(isinstance(s, (int, float)) for s in by) ): - factor = Vector(by) + by_vector = Vector(by) scale_matrix = Matrix( [ - [factor.X, 0.0, 0.0, 0.0], - [0.0, factor.Y, 0.0, 0.0], - [0.0, 0.0, factor.Z, 0.0], + [by_vector.X, 0.0, 0.0, 0.0], + [0.0, by_vector.Y, 0.0, 0.0], + [0.0, 0.0, by_vector.Z, 0.0], [0.0, 0.0, 0.0, 1.0], ] ) @@ -877,9 +901,12 @@ def scale( new_objects = [] for obj in object_list: + if obj is None: + continue current_location = obj.location + assert current_location is not None obj_at_origin = obj.located(Location(Vector())) - if isinstance(factor, float): + if isinstance(by, (int, float)): new_object = obj_at_origin.scale(factor).locate(current_location) else: new_object = obj_at_origin.transform_geometry(scale_matrix).locate( @@ -900,12 +927,12 @@ def scale( return scale_compound.unwrap(fully=False) -#:TypeVar("SplitType"): Type of objects which can be offset -SplitType = Union[Edge, Wire, Face, Solid] +SplitType: TypeAlias = Edge | Wire | Face | Solid +"""Type of objects which can be split""" def split( - objects: SplitType | Iterable[SplitType] = None, + objects: SplitType | Iterable[SplitType] | None = None, bisect_by: Plane | Face | Shell = Plane.XZ, keep: Keep = Keep.TOP, mode: Mode = Mode.REPLACE, @@ -917,8 +944,8 @@ def split( Bisect object with plane and keep either top, bottom or both. Args: - objects (Union[Edge, Wire, Face, Solid] or Iterable of), objects to split - bisect_by (Union[Plane, Face], optional): plane to segment part. + objects (Edge | Wire | Face | Solid or Iterable of), objects to split + bisect_by (Plane | Face, optional): plane to segment part. Defaults to Plane.XZ. keep (Keep, optional): selector for which segment to keep. Defaults to Keep.TOP. mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. @@ -926,7 +953,7 @@ def split( Raises: ValueError: missing objects """ - context: Builder = Builder._get_context("split") + context: Builder | None = Builder._get_context("split") if objects is None: if context is None or context is not None and context._obj is None: @@ -937,7 +964,7 @@ def split( validate_inputs(context, "split", object_list) - new_objects = [] + new_objects: list[SplitType] = [] for obj in object_list: bottom = None if keep == Keep.BOTH: @@ -963,18 +990,18 @@ def split( return split_compound -#:TypeVar("SweepType"): Type of objects which can be swept -SweepType = Union[Compound, Edge, Wire, Face, Solid] +SweepType: TypeAlias = Compound | Edge | Wire | Face | Solid +"""Type of objects which can be swept""" def sweep( - sections: SweepType | Iterable[SweepType] = None, - path: Curve | Edge | Wire | Iterable[Edge] = None, + sections: SweepType | Iterable[SweepType] | None = None, + path: Curve | Edge | Wire | Iterable[Edge] | None = None, multisection: bool = False, is_frenet: bool = False, transition: Transition = Transition.TRANSFORMED, - normal: VectorLike = None, - binormal: Edge | Wire = None, + normal: VectorLike | None = None, + binormal: Edge | Wire | None = None, clean: bool = True, mode: Mode = Mode.ADD, ) -> Part | Sketch: @@ -983,19 +1010,19 @@ def 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. + sections (Compound | Edge | Wire | Face | Solid): cross sections to sweep into object + path (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.TRANSFORMED. normal (VectorLike, optional): fixed normal. Defaults to None. - binormal (Union[Edge, Wire], optional): guide rotation along path. Defaults to None. + binormal (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") + context: Builder | None = Builder._get_context("sweep") section_list = ( [*sections] if isinstance(sections, (list, tuple, filter)) else [sections] @@ -1005,7 +1032,11 @@ def sweep( validate_inputs(context, "sweep", section_list) if path is None: - if context is None or context is not None and not context.pending_edges: + if ( + context is None + or not isinstance(context, (BuildPart, BuildSketch)) + or not context.pending_edges + ): raise ValueError("path must be provided") path_wire = Wire(context.pending_edges) context.pending_edges = [] @@ -1030,8 +1061,8 @@ def sweep( else: raise ValueError("No sections provided") - edge_list = [] - face_list = [] + edge_list: list[Edge] = [] + face_list: list[Face] = [] for sec in section_list: if isinstance(sec, (Curve, Wire, Edge)): edge_list.extend(sec.edges()) @@ -1040,6 +1071,7 @@ def sweep( # sweep to create solids new_solids = [] + binormal_mode: Wire | Vector | None if face_list: if binormal is None and normal is not None: binormal_mode = Vector(normal) @@ -1066,7 +1098,7 @@ def sweep( ] # sweep to create faces - new_faces = [] + new_faces: list[Face] = [] if edge_list: for sec in section_list: swept = Shell.sweep(sec, path_wire, transition) diff --git a/src/build123d/operations_sketch.py b/src/build123d/operations_sketch.py index d305b51b..4883be5c 100644 --- a/src/build123d/operations_sketch.py +++ b/src/build123d/operations_sketch.py @@ -28,9 +28,9 @@ """ from __future__ import annotations -from typing import Union from collections.abc import Iterable +from scipy.spatial import Voronoi from build123d.build_enums import Mode, SortBy from build123d.topology import ( Compound, @@ -46,7 +46,6 @@ from build123d.geometry import Vector, TOLERANCE from build123d.build_common import flatten_sequence, validate_inputs from build123d.build_sketch import BuildSketch -from scipy.spatial import Voronoi def full_round( @@ -77,7 +76,7 @@ def full_round( geometric center of the arc, and the third the radius of the arc """ - context: BuildSketch = BuildSketch._get_context("full_round") + context: BuildSketch | None = BuildSketch._get_context("full_round") if not isinstance(edge, Edge): raise ValueError("A single Edge must be provided") @@ -108,7 +107,11 @@ def full_round( # Refine the largest empty circle center estimate by averaging the best # three candidates. The minimum distance between the edges and this # center is the circle radius. - best_three = [(float("inf"), None), (float("inf"), None), (float("inf"), None)] + best_three: list[tuple[float, int]] = [ + (float("inf"), int()), + (float("inf"), int()), + (float("inf"), int()), + ] for i, v in enumerate(voronoi_vertices): distances = [edge_group[i].distance_to(v) for i in range(3)] @@ -125,7 +128,9 @@ def full_round( # Extract the indices of the best three and average them best_indices = [x[1] for x in best_three] - voronoi_circle_center = sum(voronoi_vertices[i] for i in best_indices) / 3 + voronoi_circle_center: Vector = ( + sum((voronoi_vertices[i] for i in best_indices), Vector(0, 0, 0)) / 3.0 + ) # Determine where the connected edges intersect with the largest empty circle connected_edges_end_points = [ @@ -142,7 +147,7 @@ def full_round( for i, e in enumerate(connected_edges) ] for param in connected_edges_end_params: - if not (0.0 < param < 1.0): + if not 0.0 < param < 1.0: raise ValueError("Invalid geometry to create the end arc") common_vertex_points = [ @@ -177,7 +182,14 @@ def full_round( ) # Recover other edges - other_edges = edge.topo_parent.edges() - topo_explore_connected_edges(edge) - [edge] + if edge.topo_parent is None: + other_edges: ShapeList[Edge] = ShapeList() + else: + other_edges = ( + edge.topo_parent.edges() + - topo_explore_connected_edges(edge) + - ShapeList([edge]) + ) # Rebuild the face # Note that the longest wire must be the perimeter and others holes @@ -195,7 +207,7 @@ def full_round( def make_face( - edges: Edge | Iterable[Edge] = None, mode: Mode = Mode.ADD + edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD ) -> Sketch: """Sketch Operation: make_face @@ -206,7 +218,7 @@ def make_face( sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: BuildSketch = BuildSketch._get_context("make_face") + context: BuildSketch | None = BuildSketch._get_context("make_face") if edges is not None: outer_edges = flatten_sequence(edges) @@ -230,7 +242,7 @@ def make_face( def make_hull( - edges: Edge | Iterable[Edge] = None, mode: Mode = Mode.ADD + edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD ) -> Sketch: """Sketch Operation: make_hull @@ -241,7 +253,7 @@ def make_hull( sketch pending edges. mode (Mode, optional): combination mode. Defaults to Mode.ADD. """ - context: BuildSketch = BuildSketch._get_context("make_hull") + context: BuildSketch | None = BuildSketch._get_context("make_hull") if edges is not None: hull_edges = flatten_sequence(edges) @@ -268,7 +280,7 @@ def make_hull( def trace( - lines: Curve | Edge | Wire | Iterable[Curve | Edge | Wire] = None, + lines: Curve | Edge | Wire | Iterable[Curve | Edge | Wire] | None = None, line_width: float = 1, mode: Mode = Mode.ADD, ) -> Sketch: @@ -277,7 +289,7 @@ def 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 + lines (Curve | Edge | Wire | Iterable[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. @@ -288,7 +300,7 @@ def trace( Returns: Sketch: Traced lines """ - context: BuildSketch = BuildSketch._get_context("trace") + context: BuildSketch | None = BuildSketch._get_context("trace") if lines is not None: trace_lines = flatten_sequence(lines) @@ -298,7 +310,7 @@ def trace( else: raise ValueError("No objects to trace") - new_faces = [] + new_faces: list[Face] = [] for edge in trace_edges: trace_pen = edge.perpendicular_line(line_width, 0) new_faces.extend(Face.sweep(trace_pen, edge).faces()) @@ -306,6 +318,7 @@ def trace( context._add_to_context(*new_faces, mode=mode) context.pending_edges = ShapeList() + # pylint: disable=no-value-for-parameter combined_faces = Face.fuse(*new_faces) if len(new_faces) > 1 else new_faces[0] result = ( Sketch(combined_faces) diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 8be2776e..5be6a8fb 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -237,7 +237,7 @@ def make_text( font: str = "Arial", font_path: str | None = None, font_style: FontStyle = FontStyle.REGULAR, - align: Align | tuple[Align, Align] = (Align.CENTER, Align.CENTER), + align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), position_on_path: float = 0.0, text_path: Edge | Wire | None = None, ) -> Compound: diff --git a/tests/test_exporters.py b/tests/test_exporters.py index a038057b..f95a92ed 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -29,6 +29,7 @@ add, mirror, section, + ThreePointArc, ) from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType @@ -173,6 +174,24 @@ def test_color(self): svg.add_shape(sketch) svg.write("test-colors.svg") + def test_svg_small_arc(self): + pnts = ((0, 0), (0, 0.000001), (0.000001, 0)) + small_arc = ThreePointArc(pnts).scale(0.01) + with self.assertWarns(UserWarning): + svg_exporter = ExportSVG() + segments = svg_exporter._circle_segments(small_arc.edges()[0], False) + self.assertEqual(len(segments), 0, "Small arc should produce no segments") + + def test_svg_small_ellipse(self): + pnts = ((0, 0), (0, 0.000001), (0.000002, 0)) + small_ellipse = ThreePointArc(pnts).scale(0.01) + with self.assertWarns(UserWarning): + svg_exporter = ExportSVG() + segments = svg_exporter._ellipse_segments(small_ellipse.edges()[0], False) + self.assertEqual( + len(segments), 0, "Small ellipse should produce no segments" + ) + @pytest.mark.parametrize( "format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"]