Merge branch 'dev' into ocp781

This commit is contained in:
jdegenstein 2025-01-13 10:46:06 -06:00 committed by GitHub
commit b665cb5889
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 386 additions and 264 deletions

View file

@ -8,7 +8,7 @@ ignore_missing_imports = True
[mypy-build123d.topology.jupyter_tools.*] [mypy-build123d.topology.jupyter_tools.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-IPython.lib.pretty.*] [mypy-IPython.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-numpy.*] [mypy-numpy.*]
@ -28,3 +28,12 @@ ignore_missing_imports = True
[mypy-vtkmodules.*] [mypy-vtkmodules.*]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-ezdxf.*]
ignore_missing_imports = True
[mypy-setuptools_scm.*]
ignore_missing_imports = True
[mypy-py_lib3mf.*]
ignore_missing_imports = True

View file

@ -243,7 +243,7 @@ class Builder(ABC):
self.builder_parent = None self.builder_parent = None
self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []} self.lasts: dict = {Vertex: [], Edge: [], Face: [], Solid: []}
self.workplanes_context = None self.workplanes_context = None
self.exit_workplanes = None self.exit_workplanes: list[Plane] = []
self.obj_before: Shape | None = None self.obj_before: Shape | None = None
self.to_combine: list[Shape] = [] self.to_combine: list[Shape] = []

View file

@ -29,7 +29,7 @@ license:
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from math import copysign, floor, gcd, log2, pi 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 from collections.abc import Iterable
@ -102,7 +102,7 @@ class Arrow(BaseSketchObject):
Args: Args:
arrow_size (float): arrow head tip to tail length 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 shaft_width (float): line width of shaft
head_at_start (bool, optional): Defaults to True. head_at_start (bool, optional): Defaults to True.
head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED. head_type (HeadType, optional): arrow head shape. Defaults to HeadType.CURVED.
@ -141,17 +141,15 @@ class Arrow(BaseSketchObject):
shaft_pen = shaft_path.perpendicular_line(shaft_width, 0) shaft_pen = shaft_path.perpendicular_line(shaft_width, 0)
shaft = sweep(shaft_pen, shaft_path, mode=Mode.PRIVATE) 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) super().__init__(arrow, rotation=0, align=None, mode=mode)
PathDescriptor = Union[ PointLike: TypeAlias = Vector | Vertex | tuple[float, float, float]
Wire, """General type for points in 3D space"""
Edge, PathDescriptor: TypeAlias = Wire | Edge | list[PointLike]
list[Union[Vector, Vertex, tuple[float, float, float]]], """General type for a path in 3D space"""
]
PointLike = Union[Vector, Vertex, tuple[float, float, float]]
@dataclass @dataclass
@ -223,7 +221,7 @@ class Draft:
def _number_with_units( def _number_with_units(
self, self,
number: float, number: float,
tolerance: float | tuple[float, float] = None, tolerance: float | tuple[float, float] | None = None,
display_units: bool | None = None, display_units: bool | None = None,
) -> str: ) -> str:
"""Convert a raw number to a unit of measurement string based on the class settings""" """Convert a raw number to a unit of measurement string based on the class settings"""
@ -295,7 +293,7 @@ class Draft:
def _label_to_str( def _label_to_str(
self, self,
label: str, label: str | None,
line_wire: Wire, line_wire: Wire,
label_angle: bool, label_angle: bool,
tolerance: float | tuple[float, float] | None, tolerance: float | tuple[float, float] | None,
@ -351,7 +349,7 @@ class DimensionLine(BaseSketchObject):
argument is desired not an actual measurement. Defaults to None. argument is desired not an actual measurement. Defaults to None.
arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement
of the start and end arrows. Defaults to (True, True). 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 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 it is shown as ± the provided value while a pair of values are shown as
separate + and - values. Defaults to None. separate + and - values. Defaults to None.
@ -368,14 +366,14 @@ class DimensionLine(BaseSketchObject):
def __init__( def __init__(
self, self,
path: PathDescriptor, path: PathDescriptor,
draft: Draft = None, draft: Draft,
sketch: Sketch = None, sketch: Sketch | None = None,
label: str = None, label: str | None = None,
arrows: tuple[bool, bool] = (True, True), arrows: tuple[bool, bool] = (True, True),
tolerance: float | tuple[float, float] = None, tolerance: float | tuple[float, float] | None = None,
label_angle: bool = False, label_angle: bool = False,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Sketch: ):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
context = BuildSketch._get_context(self) context = BuildSketch._get_context(self)
@ -452,22 +450,35 @@ class DimensionLine(BaseSketchObject):
flip_label = path_obj.tangent_at(u_value).get_angle(Vector(1, 0, 0)) >= 180 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) loc = Draft._sketch_location(path_obj, u_value, flip_label)
placed_label = label_shape.located(loc) 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 d_line += placed_label
bbox_size = d_line.bounding_box().size bbox_size = d_line.bounding_box().size
# Minimize size while avoiding intersections # Minimize size while avoiding intersections
common_area = ( if sketch is None:
0.0 if sketch is None else Sketch.intersect(d_line, sketch).area common_area = 0.0
) else:
common_area += self_intersection 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 score = (d_line.area - 10 * common_area) / bbox_size.X
d_lines[d_line] = score d_lines[d_line] = score
# Sort by score to find the best option # 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): class ExtensionLine(BaseSketchObject):
@ -489,7 +500,7 @@ class ExtensionLine(BaseSketchObject):
is desired not an actual measurement. Defaults to None. is desired not an actual measurement. Defaults to None.
arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement arrows (tuple[bool, bool], optional): a pair of boolean values controlling the placement
of the start and end arrows. Defaults to (True, True). 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 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 it is shown as ± the provided value while a pair of values are shown as
separate + and - values. Defaults to None. separate + and - values. Defaults to None.
@ -507,12 +518,12 @@ class ExtensionLine(BaseSketchObject):
border: PathDescriptor, border: PathDescriptor,
offset: float, offset: float,
draft: Draft, draft: Draft,
sketch: Sketch = None, sketch: Sketch | None = None,
label: str = None, label: str | None = None,
arrows: tuple[bool, bool] = (True, True), arrows: tuple[bool, bool] = (True, True),
tolerance: float | tuple[float, float] = None, tolerance: float | tuple[float, float] | None = None,
label_angle: bool = False, label_angle: bool = False,
project_line: VectorLike = None, project_line: VectorLike | None = None,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
@ -531,7 +542,7 @@ class ExtensionLine(BaseSketchObject):
if offset == 0: if offset == 0:
raise ValueError("A dimension line should be used if offset is 0") raise ValueError("A dimension line should be used if offset is 0")
dimension_path = object_to_measure.offset_2d( 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 = ( dimension_label_str = (
label label
@ -629,7 +640,7 @@ class TechnicalDrawing(BaseSketchObject):
title: str = "Title", title: str = "Title",
sub_title: str = "Sub Title", sub_title: str = "Sub Title",
drawing_number: str = "B3D-1", drawing_number: str = "B3D-1",
sheet_number: int = None, sheet_number: int | None = None,
drawing_scale: float = 1.0, drawing_scale: float = 1.0,
nominal_text_size: float = 10.0, nominal_text_size: float = 10.0,
line_width: float = 0.5, line_width: float = 0.5,
@ -691,12 +702,12 @@ class TechnicalDrawing(BaseSketchObject):
4: 3 / 12, 4: 3 / 12,
5: 5 / 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]: for y_index in [-0.5, 0.5]:
grid_labels += Pos( grid_labels += Pos(
x_centers[i] * frame_width, x_centers[i] * frame_width,
y_index * (frame_height + 1.5 * nominal_text_size), 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 # Text Box Frame
bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5 bf_pnt1 = frame_wire.edges().sort_by(Axis.Y)[0] @ 0.5

View file

@ -34,9 +34,9 @@ import math
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from copy import copy from copy import copy
from enum import Enum, auto from enum import Enum, auto
from os import PathLike, fsdecode, fspath from os import PathLike, fsdecode
from pathlib import Path from typing import Any, TypeAlias
from typing import List, Optional, Tuple, Union from warnings import warn
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
@ -45,16 +45,15 @@ import svgpathtools as PT
from ezdxf import zoom from ezdxf import zoom
from ezdxf.colors import RGB, aci2rgb from ezdxf.colors import RGB, aci2rgb
from ezdxf.math import Vec2 from ezdxf.math import Vec2
from OCP.BRepLib import BRepLib # type: ignore from OCP.BRepLib import BRepLib
from OCP.BRepTools import BRepTools_WireExplorer # type: ignore from OCP.Geom import Geom_BezierCurve
from OCP.Geom import Geom_BezierCurve # type: ignore from OCP.GeomConvert import GeomConvert
from OCP.GeomConvert import GeomConvert # type: ignore from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve
from OCP.GeomConvert import GeomConvert_BSplineCurveToBezierCurve # type: ignore from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ
from OCP.gp import gp_Ax2, gp_Dir, gp_Pnt, gp_Vec, gp_XYZ # type: ignore from OCP.HLRAlgo import HLRAlgo_Projector
from OCP.HLRAlgo import HLRAlgo_Projector # type: ignore from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape # type: ignore from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum
from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum # type: ignore from OCP.TopExp import TopExp_Explorer
from OCP.TopExp import TopExp_Explorer # type: ignore
from OCP.TopoDS import TopoDS from OCP.TopoDS import TopoDS
from typing_extensions import Self from typing_extensions import Self
@ -69,7 +68,8 @@ from build123d.topology import (
) )
from build123d.build_common import UNITS_PER_METER 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 @@ class Drawing:
self, self,
shape: Shape, shape: Shape,
*, *,
look_at: VectorLike = None, look_at: VectorLike | None = None,
look_from: VectorLike = (1, -1, 1), look_from: VectorLike = (1, -1, 1),
look_up: VectorLike = (0, 0, 1), look_up: VectorLike = (0, 0, 1),
with_hidden: bool = True, with_hidden: bool = True,
@ -562,7 +562,7 @@ class ExportDXF(Export2D):
""" """
# ezdxf :doc:`line type <ezdxf-stable:concepts/linetypes>`. # ezdxf :doc:`line type <ezdxf-stable:concepts/linetypes>`.
kwargs = {} kwargs: dict[str, Any] = {}
if line_type is not None: if line_type is not None:
linetype = self._linetype(line_type) linetype = self._linetype(line_type)
@ -587,7 +587,7 @@ class ExportDXF(Export2D):
# The linetype is not in the doc yet. # The linetype is not in the doc yet.
# Add it from our available definitions. # Add it from our available definitions.
if linetype in Export2D.LINETYPE_DEFS: 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( self._document.linetypes.add(
name=linetype, name=linetype,
pattern=[self._linetype_scale * v for v in pattern], pattern=[self._linetype_scale * v for v in pattern],
@ -605,7 +605,7 @@ class ExportDXF(Export2D):
Adds a shape to the specified layer. Adds a shape to the specified layer.
Args: 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. 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 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 "". added. If not specified, the default layer will be used. Defaults to "".
@ -641,8 +641,8 @@ class ExportDXF(Export2D):
Writes the DXF data to the specified file name. Writes the DXF data to the specified file name.
Args: Args:
file_name (Union[PathLike, str, bytes]): The file name (including path) where the DXF data will file_name (PathLike | str | bytes): The file name (including path) where
be written. the DXF data will be written.
""" """
# Reset the main CAD viewport of the model space to the # Reset the main CAD viewport of the model space to the
# extents of its entities. # extents of its entities.
@ -757,6 +757,8 @@ class ExportDXF(Export2D):
) )
# need to apply the transform on the geometry level # 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() t = edge.location.wrapped.Transformation()
spline.Transform(t) spline.Transform(t)
@ -828,17 +830,17 @@ class ExportSVG(Export2D):
should fit the strokes of the shapes. Defaults to True. should fit the strokes of the shapes. Defaults to True.
precision (int, optional): The number of decimal places used for rounding precision (int, optional): The number of decimal places used for rounding
coordinates in the SVG. Defaults to 6. 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. for shapes. It can be specified as a ColorIndex, an RGB tuple, or None.
Defaults to 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. shapes. It can be specified as a ColorIndex or an RGB tuple, or None.
Defaults to Export2D.DEFAULT_COLOR_INDEX. Defaults to Export2D.DEFAULT_COLOR_INDEX.
line_weight (float, optional): The default line weight (stroke width) for line_weight (float, optional): The default line weight (stroke width) for
shapes, in millimeters. Defaults to Export2D.DEFAULT_LINE_WEIGHT. shapes, in millimeters. Defaults to Export2D.DEFAULT_LINE_WEIGHT.
line_type (LineType, optional): The default line type for shapes. It should be line_type (LineType, optional): The default line type for shapes. It should be
a LineType enum. Defaults to Export2D.DEFAULT_LINE_TYPE. 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. Can be either a DotLength enum or a float value in tenths of an inch.
Defaults to DotLength.INKSCAPE_COMPAT. Defaults to DotLength.INKSCAPE_COMPAT.
@ -878,21 +880,28 @@ class ExportSVG(Export2D):
line_type: LineType, line_type: LineType,
): ):
def convert_color( def convert_color(
c: ColorIndex | RGB | Color | None, input_color: ColorIndex | RGB | Color | None,
) -> Color | None: ) -> Color | None:
if isinstance(c, ColorIndex): if isinstance(input_color, ColorIndex):
# The easydxf color indices BLACK and WHITE have the same # The easydxf color indices BLACK and WHITE have the same
# value (7), and are both mapped to (255,255,255) by the # value (7), and are both mapped to (255,255,255) by the
# aci2rgb() function. We prefer (0,0,0). # aci2rgb() function. We prefer (0,0,0).
if c == ColorIndex.BLACK: if input_color == ColorIndex.BLACK:
c = RGB(0, 0, 0) rgb_color = RGB(0, 0, 0)
else: else:
c = aci2rgb(c.value) rgb_color = aci2rgb(input_color.value)
elif isinstance(c, tuple): elif isinstance(input_color, tuple):
c = RGB(*c) rgb_color = RGB(*input_color)
if isinstance(c, RGB): else:
c = Color(*c.to_floats(), 1) rgb_color = input_color # If not ColorIndex or tuple, it's already RGB or None
return c
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.name = name
self.fill_color = convert_color(fill_color) self.fill_color = convert_color(fill_color)
@ -929,7 +938,7 @@ class ExportSVG(Export2D):
self.dot_length = dot_length self.dot_length = dot_length
self._non_planar_point_count = 0 self._non_planar_point_count = 0
self._layers: dict[str, ExportSVG._Layer] = {} self._layers: dict[str, ExportSVG._Layer] = {}
self._bounds: BoundBox = None self._bounds: BoundBox | None = None
# Add the default layer. # Add the default layer.
self.add_layer( self.add_layer(
@ -957,10 +966,10 @@ class ExportSVG(Export2D):
Args: Args:
name (str): The name of the layer. Must be unique among all layers. 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, on this layer. It can be specified as a ColorIndex, an RGB tuple,
a Color, or None. Defaults to None. 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, this layer. It can be specified as a ColorIndex or an RGB tuple,
a Color, or None. Defaults to Export2D.DEFAULT_COLOR_INDEX. a Color, or None. Defaults to Export2D.DEFAULT_COLOR_INDEX.
line_weight (float, optional): The line weight (stroke width) for shapes on line_weight (float, optional): The line weight (stroke width) for shapes on
@ -1002,7 +1011,7 @@ class ExportSVG(Export2D):
Adds a shape or a collection of shapes to the specified layer. Adds a shape or a collection of shapes to the specified layer.
Args: 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. 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. layer (str, optional): The name of the layer where the shape(s) will be added.
Defaults to "". Defaults to "".
@ -1014,12 +1023,12 @@ class ExportSVG(Export2D):
""" """
if layer not in self._layers: if layer not in self._layers:
raise ValueError(f"Undefined layer: {layer}.") raise ValueError(f"Undefined layer: {layer}.")
layer = self._layers[layer] _layer = self._layers[layer]
if isinstance(shape, Shape): if isinstance(shape, Shape):
self._add_single_shape(shape, layer, reverse_wires) self._add_single_shape(shape, _layer, reverse_wires)
else: else:
for s in shape: 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): def _add_single_shape(self, shape: Shape, layer: _Layer, reverse_wires: bool):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
@ -1188,6 +1197,12 @@ class ExportSVG(Export2D):
def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: def _circle_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]:
# pylint: disable=too-many-locals # 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() curve = edge.geom_adaptor()
circle = curve.Circle() circle = curve.Circle()
radius = circle.Radius() radius = circle.Radius()
@ -1234,6 +1249,12 @@ class ExportSVG(Export2D):
def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: def _ellipse_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]:
# pylint: disable=too-many-locals # 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() curve = edge.geom_adaptor()
ellipse = curve.Ellipse() ellipse = curve.Ellipse()
minor_radius = ellipse.MinorRadius() minor_radius = ellipse.MinorRadius()
@ -1283,6 +1304,8 @@ class ExportSVG(Export2D):
u2 = adaptor.LastParameter() u2 = adaptor.LastParameter()
# Apply the shape location to the geometry. # 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() t = edge.location.wrapped.Transformation()
spline.Transform(t) spline.Transform(t)
# describe_bspline(spline) # describe_bspline(spline)
@ -1347,6 +1370,8 @@ class ExportSVG(Export2D):
} }
def _edge_segments(self, edge: Edge, reverse: bool) -> list[PathSegment]: 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 edge_reversed = edge.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
geom_type = edge.geom_type geom_type = edge.geom_type
segments = self._SEGMENT_LOOKUP.get(geom_type, ExportSVG._other_segments) segments = self._SEGMENT_LOOKUP.get(geom_type, ExportSVG._other_segments)
@ -1391,10 +1416,12 @@ class ExportSVG(Export2D):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def _group_for_layer(self, layer: _Layer, attribs: dict = None) -> ET.Element: def _group_for_layer(
def _color_attribs(c: Color) -> tuple[str, str]: self, layer: _Layer, attribs: dict | None = None
if c: ) -> ET.Element:
(r, g, b, a) = tuple(c) 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)) (r, g, b, a) = (int(r * 255), int(g * 255), int(b * 255), round(a, 3))
rgb = f"rgb({r},{g},{b})" rgb = f"rgb({r},{g},{b})"
opacity = f"{a}" if a < 1 else None opacity = f"{a}" if a < 1 else None
@ -1403,9 +1430,9 @@ class ExportSVG(Export2D):
if attribs is None: if attribs is None:
attribs = {} attribs = {}
(fill, fill_opacity) = _color_attribs(layer.fill_color) fill, fill_opacity = _color_attribs(layer.fill_color)
attribs["fill"] = fill attribs["fill"] = fill
if fill_opacity: if fill_opacity is not None:
attribs["fill-opacity"] = fill_opacity attribs["fill-opacity"] = fill_opacity
(stroke, stroke_opacity) = _color_attribs(layer.line_color) (stroke, stroke_opacity) = _color_attribs(layer.line_color)
attribs["stroke"] = stroke attribs["stroke"] = stroke
@ -1435,10 +1462,12 @@ class ExportSVG(Export2D):
Writes the SVG data to the specified file path. Writes the SVG data to the specified file path.
Args: 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 # pylint: disable=too-many-locals
bb = self._bounds bb = self._bounds
if bb is None:
raise ValueError("No shapes to export.")
doc_margin = self.margin doc_margin = self.margin
if self.fit_to_stroke: if self.fit_to_stroke:
max_line_weight = max(l.line_weight for l in self._layers.values()) max_line_weight = max(l.line_weight for l in self._layers.values())
@ -1479,4 +1508,5 @@ class ExportSVG(Export2D):
xml = ET.ElementTree(svg) xml = ET.ElementTree(svg)
ET.indent(xml, " ") 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)

View file

@ -30,12 +30,11 @@ from __future__ import annotations
from math import radians, tan from math import radians, tan
from typing import Union
from build123d.build_common import LocationList, validate_inputs from build123d.build_common import LocationList, validate_inputs
from build123d.build_enums import Align, Mode from build123d.build_enums import Align, Mode
from build123d.build_part import BuildPart from build123d.build_part import BuildPart
from build123d.geometry import Location, Plane, Rotation, RotationLike, Vector from build123d.geometry import Location, Plane, Rotation, RotationLike
from build123d.topology import Compound, Part, Solid, tuplify from build123d.topology import Compound, Part, ShapeList, Solid, tuplify
class BasePartObject(Part): class BasePartObject(Part):
@ -46,7 +45,7 @@ class BasePartObject(Part):
Args: Args:
solid (Solid): object to create solid (Solid): object to create
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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. or max of object. Defaults to None.
mode (Mode, optional): combination mode. Defaults to Mode.ADD. mode (Mode, optional): combination mode. Defaults to Mode.ADD.
""" """
@ -57,7 +56,7 @@ class BasePartObject(Part):
self, self,
part: Part | Solid, part: Part | Solid,
rotation: RotationLike = (0, 0, 0), rotation: RotationLike = (0, 0, 0),
align: Align | tuple[Align, Align, Align] = None, align: Align | tuple[Align, Align, Align] | None = None,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
if align is not None: if align is not None:
@ -66,7 +65,7 @@ class BasePartObject(Part):
offset = bbox.to_align_offset(align) offset = bbox.to_align_offset(align)
part.move(Location(offset)) 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 rotate = Rotation(*rotation) if isinstance(rotation, tuple) else rotation
self.rotation = rotate self.rotation = rotate
if context is None: if context is None:
@ -111,7 +110,7 @@ class Box(BasePartObject):
width (float): box size width (float): box size
height (float): box size height (float): box size
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -131,7 +130,7 @@ class Box(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.length = length self.length = length
@ -156,7 +155,7 @@ class Cone(BasePartObject):
height (float): cone size height (float): cone size
arc_size (float, optional): angular size of cone. Defaults to 360. arc_size (float, optional): angular size of cone. Defaults to 360.
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -177,7 +176,7 @@ class Cone(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.bottom_radius = bottom_radius self.bottom_radius = bottom_radius
@ -218,10 +217,10 @@ class CounterBoreHole(BasePartObject):
radius: float, radius: float,
counter_bore_radius: float, counter_bore_radius: float,
counter_bore_depth: float, counter_bore_depth: float,
depth: float = None, depth: float | None = None,
mode: Mode = Mode.SUBTRACT, mode: Mode = Mode.SUBTRACT,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -235,7 +234,7 @@ class CounterBoreHole(BasePartObject):
raise ValueError("No depth provided") raise ValueError("No depth provided")
self.mode = mode 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)) radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1))
).fuse( ).fuse(
Solid.make_cylinder( Solid.make_cylinder(
@ -244,6 +243,10 @@ class CounterBoreHole(BasePartObject):
Plane((0, 0, -counter_bore_depth)), 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) super().__init__(part=solid, rotation=(0, 0, 0), mode=mode)
@ -266,11 +269,11 @@ class CounterSinkHole(BasePartObject):
self, self,
radius: float, radius: float,
counter_sink_radius: float, counter_sink_radius: float,
depth: float = None, depth: float | None = None,
counter_sink_angle: float = 82, # Common tip angle counter_sink_angle: float = 82, # Common tip angle
mode: Mode = Mode.SUBTRACT, mode: Mode = Mode.SUBTRACT,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -285,7 +288,7 @@ class CounterSinkHole(BasePartObject):
self.mode = mode self.mode = mode
cone_height = counter_sink_radius / tan(radians(counter_sink_angle / 2.0)) 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)) radius, self.hole_depth, Plane(origin=(0, 0, 0), z_dir=(0, 0, -1))
).fuse( ).fuse(
Solid.make_cone( Solid.make_cone(
@ -296,6 +299,11 @@ class CounterSinkHole(BasePartObject):
), ),
Solid.make_cylinder(counter_sink_radius, self.hole_depth), 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) super().__init__(part=solid, rotation=(0, 0, 0), mode=mode)
@ -309,7 +317,7 @@ class Cylinder(BasePartObject):
height (float): cylinder size height (float): cylinder size
arc_size (float, optional): angular size of cone. Defaults to 360. arc_size (float, optional): angular size of cone. Defaults to 360.
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -329,7 +337,7 @@ class Cylinder(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -363,10 +371,10 @@ class Hole(BasePartObject):
def __init__( def __init__(
self, self,
radius: float, radius: float,
depth: float = None, depth: float | None = None,
mode: Mode = Mode.SUBTRACT, mode: Mode = Mode.SUBTRACT,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -405,7 +413,7 @@ class Sphere(BasePartObject):
arc_size2 (float, optional): angular size of sphere. Defaults to 90. arc_size2 (float, optional): angular size of sphere. Defaults to 90.
arc_size3 (float, optional): angular size of sphere. Defaults to 360. arc_size3 (float, optional): angular size of sphere. Defaults to 360.
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -426,7 +434,7 @@ class Sphere(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -458,7 +466,7 @@ class Torus(BasePartObject):
major_arc_size (float, optional): angular size of torus. Defaults to 0. major_arc_size (float, optional): angular size of torus. Defaults to 0.
minor_arc_size (float, optional): angular size or torus. Defaults to 360. 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). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -480,7 +488,7 @@ class Torus(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.major_radius = major_radius self.major_radius = major_radius
@ -516,7 +524,7 @@ class Wedge(BasePartObject):
xmax (float): maximum X location xmax (float): maximum X location
zmax (float): maximum Z location zmax (float): maximum Z location
rotation (RotationLike, optional): angles to rotate about axes. Defaults to (0, 0, 0). 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). or max of object. Defaults to (Align.CENTER, Align.CENTER, Align.CENTER).
mode (Mode, optional): combine mode. Defaults to Mode.ADD. mode (Mode, optional): combine mode. Defaults to Mode.ADD.
""" """
@ -540,7 +548,7 @@ class Wedge(BasePartObject):
), ),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context: BuildPart = BuildPart._get_context(self) context: BuildPart | None = BuildPart._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
if any([value <= 0 for value in [xsize, ysize, zsize]]): if any([value <= 0 for value in [xsize, ysize, zsize]]):

View file

@ -31,7 +31,7 @@ from __future__ import annotations
import trianglesolver import trianglesolver
from math import cos, degrees, pi, radians, sin, tan from math import cos, degrees, pi, radians, sin, tan
from typing import Union from typing import cast
from collections.abc import Iterable from collections.abc import Iterable
@ -85,7 +85,7 @@ class BaseSketchObject(Sketch):
align = tuplify(align, 2) align = tuplify(align, 2)
obj.move(Location(obj.bounding_box().to_align_offset(align))) 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: if context is None:
new_faces = obj.moved(Rotation(0, 0, rotation)).faces() new_faces = obj.moved(Rotation(0, 0, rotation)).faces()
@ -95,11 +95,11 @@ class BaseSketchObject(Sketch):
obj = obj.moved(Rotation(0, 0, rotation)) obj = obj.moved(Rotation(0, 0, rotation))
new_faces = [ new_faces = ShapeList(
face.moved(location) face.moved(location)
for face in obj.faces() for face in obj.faces()
for location in LocationList._get_context().local_locations for location in LocationList._get_context().local_locations
] )
if isinstance(context, BuildSketch): if isinstance(context, BuildSketch):
context._add_to_context(*new_faces, mode=mode) context._add_to_context(*new_faces, mode=mode)
@ -126,7 +126,7 @@ class Circle(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.radius = radius self.radius = radius
@ -160,7 +160,7 @@ class Ellipse(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.x_radius = x_radius self.x_radius = x_radius
@ -199,7 +199,7 @@ class Polygon(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
flattened_pts = flatten_sequence(*pts) flattened_pts = flatten_sequence(*pts)
@ -235,7 +235,7 @@ class Rectangle(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.width = width self.width = width
@ -272,7 +272,7 @@ class RectangleRounded(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
if width <= 2 * radius or height <= 2 * radius: if width <= 2 * radius or height <= 2 * radius:
@ -317,7 +317,7 @@ class RegularPolygon(BaseSketchObject):
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
if side_count < 3: if side_count < 3:
@ -381,7 +381,7 @@ class SlotArc(BaseSketchObject):
rotation: float = 0, rotation: float = 0,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.arc = arc self.arc = arc
@ -417,7 +417,7 @@ class SlotCenterPoint(BaseSketchObject):
rotation: float = 0, rotation: float = 0,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
center_v = Vector(center) center_v = Vector(center)
@ -472,7 +472,7 @@ class SlotCenterToCenter(BaseSketchObject):
f"Requires center_separation > 0. Got: {center_separation=}" f"Requires center_separation > 0. Got: {center_separation=}"
) )
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.center_separation = center_separation self.center_separation = center_separation
@ -518,14 +518,14 @@ class SlotOverall(BaseSketchObject):
f"Slot requires that width > height. Got: {width=}, {height=}" 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) validate_inputs(context, self)
self.width = width self.width = width
self.slot_height = height self.slot_height = height
if width != height: if width != height:
face: Face | None = Face( face = Face(
Wire( Wire(
[ [
Edge.make_line(Vector(-width / 2 + height / 2, 0, 0), Vector()), Edge.make_line(Vector(-width / 2 + height / 2, 0, 0), Vector()),
@ -534,7 +534,7 @@ class SlotOverall(BaseSketchObject):
).offset_2d(height / 2) ).offset_2d(height / 2)
) )
else: else:
face = Circle(width / 2, mode=mode).face() face = cast(Face, Circle(width / 2, mode=mode).face())
super().__init__(face, rotation, align, mode) super().__init__(face, rotation, align, mode)
@ -574,7 +574,7 @@ class Text(BaseSketchObject):
rotation: float = 0.0, rotation: float = 0.0,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
self.txt = txt self.txt = txt
@ -633,7 +633,7 @@ class Trapezoid(BaseSketchObject):
align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER), align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
right_side_angle = left_side_angle if not right_side_angle else right_side_angle right_side_angle = left_side_angle if not right_side_angle else right_side_angle
@ -720,7 +720,7 @@ class Triangle(BaseSketchObject):
rotation: float = 0, rotation: float = 0,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
): ):
context = BuildSketch._get_context(self) context: BuildSketch | None = BuildSketch._get_context(self)
validate_inputs(context, self) validate_inputs(context, self)
if [v is None for v in [a, b, c]].count(True) == 3 or [ if [v is None for v in [a, b, c]].count(True) == 3 or [

View file

@ -30,7 +30,7 @@ license:
import copy import copy
import logging import logging
from math import radians, tan from math import radians, tan
from typing import Union from typing import cast, TypeAlias
from collections.abc import Iterable from collections.abc import Iterable
@ -78,13 +78,13 @@ from build123d.topology import (
logging.getLogger("build123d").addHandler(logging.NullHandler()) logging.getLogger("build123d").addHandler(logging.NullHandler())
logger = logging.getLogger("build123d") logger = logging.getLogger("build123d")
#:TypeVar("AddType"): Type of objects which can be added to a builder AddType: TypeAlias = Edge | Wire | Face | Solid | Compound | Builder
AddType = Union[Edge, Wire, Face, Solid, Compound, Builder] """Type of objects which can be added to a builder"""
def add( def add(
objects: AddType | Iterable[AddType], objects: AddType | Iterable[AddType],
rotation: float | RotationLike = None, rotation: float | RotationLike | None = None,
clean: bool = True, clean: bool = True,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Compound: ) -> Compound:
@ -101,22 +101,28 @@ def add(
Edges and Wires are added to line. Edges and Wires are added to line.
Args: Args:
objects (Union[Edge, Wire, Face, Solid, Compound] or Iterable of): objects to add objects (Edge | Wire | Face | Solid | Compound or Iterable of): objects to add
rotation (Union[float, RotationLike], optional): rotation angle for sketch, rotation (float | RotationLike, optional): rotation angle for sketch,
rotation about each axis for part. Defaults to None. rotation about each axis for part. Defaults to None.
clean (bool, optional): Remove extraneous internal structure. Defaults to True. clean (bool, optional): Remove extraneous internal structure. Defaults to True.
mode (Mode, optional): combine mode. Defaults to Mode.ADD. 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: if context is None:
raise RuntimeError("Add must have an active builder context") 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 = [ 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) validate_inputs(context, "add", object_iter)
@ -201,7 +207,7 @@ def add(
def bounding_box( def bounding_box(
objects: Shape | Iterable[Shape] = None, objects: Shape | Iterable[Shape] | None = None,
mode: Mode = Mode.PRIVATE, mode: Mode = Mode.PRIVATE,
) -> Sketch | Part: ) -> Sketch | Part:
"""Generic Operation: Add Bounding Box """Generic Operation: Add Bounding Box
@ -214,7 +220,7 @@ def bounding_box(
objects (Shape or Iterable of): objects to create bbox for objects (Shape or Iterable of): objects to create bbox for
mode (Mode, optional): combination mode. Defaults to Mode.ADD. 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 objects is None:
if context is None or context is not None and context._obj 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) return Part(Compound(new_objects).wrapped)
#:TypeVar("ChamferFilletType"): Type of objects which can be chamfered or filleted ChamferFilletType: TypeAlias = Edge | Vertex
ChamferFilletType = Union[Edge, Vertex] """Type of objects which can be chamfered or filleted"""
def chamfer( def chamfer(
objects: ChamferFilletType | Iterable[ChamferFilletType], objects: ChamferFilletType | Iterable[ChamferFilletType],
length: float, length: float,
length2: float = None, length2: float | None = None,
angle: float = None, angle: float | None = None,
reference: Edge | Face = None, reference: Edge | Face | None = None,
) -> Sketch | Part: ) -> Sketch | Part:
"""Generic Operation: chamfer """Generic Operation: chamfer
@ -279,11 +285,11 @@ def chamfer(
Chamfer the given sequence of edges or vertices. Chamfer the given sequence of edges or vertices.
Args: 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 length (float): chamfer size
length2 (float, optional): asymmetric chamfer size. Defaults to None. length2 (float, optional): asymmetric chamfer size. Defaults to None.
angle (float, optional): chamfer angle in degrees. 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 be part of the face. Vertex/Vertices must be part of edge
Raises: Raises:
@ -293,7 +299,7 @@ def chamfer(
ValueError: Only one of length2 or angle should be provided ValueError: Only one of length2 or angle should be provided
ValueError: reference can only be used in conjunction with length2 or angle 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: if length2 and angle:
raise ValueError("Only one of length2 or angle should be provided") 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") raise ValueError("1D fillet operation takes only Vertices")
# Remove any end vertices as these can't be filleted # Remove any end vertices as these can't be filleted
if not target.is_closed: if not target.is_closed:
object_list = filter( object_list = ShapeList(
lambda v: not ( filter(
isclose_b( lambda v: not (
(Vector(*v.to_tuple()) - target.position_at(0)).length, isclose_b(
0.0, (Vector(*v.to_tuple()) - target.position_at(0)).length,
) 0.0,
or isclose_b( )
(Vector(*v.to_tuple()) - target.position_at(1)).length, or isclose_b(
0.0, (Vector(*v.to_tuple()) - target.position_at(1)).length,
) 0.0,
), )
object_list, ),
object_list,
)
) )
new_wire = target.chamfer_2d(length, length2, object_list, reference) new_wire = target.chamfer_2d(length, length2, object_list, reference)
if context is not None: if context is not None:
context._add_to_context(new_wire, mode=Mode.REPLACE) context._add_to_context(new_wire, mode=Mode.REPLACE)
return new_wire return new_wire
raise ValueError("Invalid object dimension")
def fillet( def fillet(
objects: ChamferFilletType | Iterable[ChamferFilletType], objects: ChamferFilletType | Iterable[ChamferFilletType],
@ -398,7 +408,7 @@ def fillet(
either end of an open line will be automatically skipped. either end of an open line will be automatically skipped.
Args: 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 radius (float): fillet size - must be less than 1/2 local width
Raises: Raises:
@ -407,7 +417,7 @@ def fillet(
ValueError: objects must be Vertices ValueError: objects must be Vertices
ValueError: nothing to fillet 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 ( if (objects is None and context is None) or (
objects is None and context is not None and context._obj is None 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") raise ValueError("1D fillet operation takes only Vertices")
# Remove any end vertices as these can't be filleted # Remove any end vertices as these can't be filleted
if not target.is_closed: if not target.is_closed:
object_list = filter( object_list = ShapeList(
lambda v: not ( filter(
isclose_b( lambda v: not (
(Vector(*v.to_tuple()) - target.position_at(0)).length, isclose_b(
0.0, (Vector(*v.to_tuple()) - target.position_at(0)).length,
) 0.0,
or isclose_b( )
(Vector(*v.to_tuple()) - target.position_at(1)).length, or isclose_b(
0.0, (Vector(*v.to_tuple()) - target.position_at(1)).length,
) 0.0,
), )
object_list, ),
object_list,
)
) )
new_wire = target.fillet_2d(radius, object_list) new_wire = target.fillet_2d(radius, object_list)
if context is not None: if context is not None:
context._add_to_context(new_wire, mode=Mode.REPLACE) context._add_to_context(new_wire, mode=Mode.REPLACE)
return new_wire 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( def mirror(
objects: MirrorType | Iterable[MirrorType] = None, objects: MirrorType | Iterable[MirrorType] | None = None,
about: Plane = Plane.XZ, about: Plane = Plane.XZ,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Curve | Sketch | Part | Compound: ) -> Curve | Sketch | Part | Compound:
@ -501,15 +515,18 @@ def mirror(
Mirror a sequence of objects over the given plane. Mirror a sequence of objects over the given plane.
Args: 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". about (Plane, optional): reference plane. Defaults to "XZ".
mode (Mode, optional): combination mode. Defaults to Mode.ADD. mode (Mode, optional): combination mode. Defaults to Mode.ADD.
Raises: Raises:
ValueError: missing objects ValueError: missing objects
""" """
context: Builder = Builder._get_context("mirror") context: Builder | None = Builder._get_context("mirror")
object_list = objects if isinstance(objects, Iterable) else [objects] if isinstance(objects, Iterable) and not isinstance(objects, Compound):
object_list = list(objects)
else:
object_list = [objects]
if objects is None: if objects is None:
if context is None or context is not None and context._obj 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 return mirrored_compound
#:TypeVar("OffsetType"): Type of objects which can be offset OffsetType: TypeAlias = Edge | Face | Solid | Compound
OffsetType = Union[Edge, Face, Solid, Compound] """Type of objects which can be offset"""
def offset( def offset(
objects: OffsetType | Iterable[OffsetType] = None, objects: OffsetType | Iterable[OffsetType] | None = None,
amount: float = 0, amount: float = 0,
openings: Face | list[Face] = None, openings: Face | list[Face] | None = None,
kind: Kind = Kind.ARC, kind: Kind = Kind.ARC,
side: Side = Side.BOTH, side: Side = Side.BOTH,
closed: bool = True, closed: bool = True,
min_edge_length: float = None, min_edge_length: float | None = None,
mode: Mode = Mode.REPLACE, mode: Mode = Mode.REPLACE,
) -> Curve | Sketch | Part | Compound: ) -> Curve | Sketch | Part | Compound:
"""Generic Operation: offset """Generic Operation: offset
@ -559,7 +576,7 @@ def offset(
a hollow box with no lid. a hollow box with no lid.
Args: 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 amount (float): positive values external, negative internal
openings (list[Face], optional), sequence of faces to open in part. openings (list[Face], optional), sequence of faces to open in part.
Defaults to None. Defaults to None.
@ -575,7 +592,7 @@ def offset(
ValueError: missing objects ValueError: missing objects
ValueError: Invalid object type ValueError: Invalid object type
""" """
context: Builder = Builder._get_context("offset") context: Builder | None = Builder._get_context("offset")
if objects is None: if objects is None:
if context is None or context is not None and context._obj is None: if context is None or context is not None and context._obj is None:
@ -624,15 +641,19 @@ def offset(
pass pass
# inner wires may go beyond the outer wire so subtract faces # inner wires may go beyond the outer wire so subtract faces
new_face = Face(outer_wire) 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: if (new_face.normal_at() - face.normal_at()).length > 0.001:
new_face = -new_face 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 edges:
if len(edges) == 1 and edges[0].geom_type == GeomType.LINE: if len(edges) == 1 and edges[0].geom_type == GeomType.LINE:
new_wires = [ new_wires = [
@ -679,14 +700,14 @@ def offset(
return offset_compound return offset_compound
#:TypeVar("ProjectType"): Type of objects which can be projected ProjectType: TypeAlias = Edge | Face | Wire | Vector | Vertex
ProjectType = Union[Edge, Face, Wire, Vector, Vertex] """Type of objects which can be projected"""
def project( def project(
objects: ProjectType | Iterable[ProjectType] = None, objects: ProjectType | Iterable[ProjectType] | None = None,
workplane: Plane = None, workplane: Plane | None = None,
target: Solid | Compound | Part = None, target: Solid | Compound | Part | None = None,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Curve | Sketch | Compound | ShapeList[Vector]: ) -> Curve | Sketch | Compound | ShapeList[Vector]:
"""Generic Operation: project """Generic Operation: project
@ -704,7 +725,7 @@ def project(
BuildSketch and Edge/Wires into BuildLine. BuildSketch and Edge/Wires into BuildLine.
Args: 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 objects or points to project
workplane (Plane, optional): screen workplane workplane (Plane, optional): screen workplane
mode (Mode, optional): combination mode. Defaults to Mode.ADD. 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 ValueError: Edges, wires and points can only be projected in PRIVATE mode
RuntimeError: BuildPart doesn't have a project operation 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): if isinstance(objects, GroupBy):
raise ValueError("project doesn't accept group_by, did you miss [n]?") 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 object_size = Compound(children=shape_list).bounding_box(optimal=False).diagonal
point_list = [o for o in object_list if isinstance(o, (Vector, Vertex))] vct_vrt_list = [o for o in object_list if isinstance(o, (Vector, Vertex))]
point_list = [Vector(pnt) for pnt in point_list] point_list = [Vector(pnt) for pnt in vct_vrt_list]
face_list = [o for o in object_list if isinstance(o, Face)] 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))] line_list = [o for o in object_list if isinstance(o, (Edge, Wire))]
@ -764,6 +785,7 @@ def project(
raise ValueError( raise ValueError(
"Edges, wires and points can only be projected in PRIVATE mode" "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 # BuildLine and BuildSketch are from target to workplane while BuildPart is
# from workplane to target so the projection direction needs to be flipped # from workplane to target so the projection direction needs to be flipped
@ -775,7 +797,7 @@ def project(
target = context._obj target = context._obj
projection_flip = -1 projection_flip = -1
else: 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") validate_inputs(context, "project")
@ -783,37 +805,39 @@ def project(
obj: Shape obj: Shape
for obj in face_list + line_list: for obj in face_list + line_list:
obj_to_screen = (target.center() - obj.center()).normalized() obj_to_screen = (target.center() - obj.center()).normalized()
if workplane.from_local_coords(obj_to_screen).Z < 0: if working_plane.from_local_coords(obj_to_screen).Z < 0:
projection_direction = -workplane.z_dir * projection_flip projection_direction = -working_plane.z_dir * projection_flip
else: else:
projection_direction = workplane.z_dir * projection_flip projection_direction = working_plane.z_dir * projection_flip
projection = obj.project_to_shape(target, projection_direction) projection = obj.project_to_shape(target, projection_direction)
if projection: if projection:
if isinstance(context, BuildSketch): if isinstance(context, BuildSketch):
projected_shapes.extend( 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): elif isinstance(context, BuildLine):
projected_shapes.extend(projection) projected_shapes.extend(projection)
else: # BuildPart else: # BuildPart
projected_shapes.append(projection[0]) projected_shapes.append(projection[0])
projected_points = [] projected_points: ShapeList[Vector] = ShapeList()
for pnt in point_list: for pnt in point_list:
pnt_to_target = (workplane.origin - pnt).normalized() pnt_to_target = (working_plane.origin - pnt).normalized()
if workplane.from_local_coords(pnt_to_target).Z < 0: if working_plane.from_local_coords(pnt_to_target).Z < 0:
projection_axis = -Axis(pnt, workplane.z_dir * projection_flip) projection_axis = -Axis(pnt, working_plane.z_dir * projection_flip)
else: else:
projection_axis = Axis(pnt, workplane.z_dir * projection_flip) projection_axis = Axis(pnt, working_plane.z_dir * projection_flip)
projection = workplane.to_local_coords(workplane.intersect(projection_axis)) intersection = working_plane.intersect(projection_axis)
if projection is not None: if isinstance(intersection, Axis):
projected_points.append(projection) 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: if context is not None:
context._add_to_context(*projected_shapes, mode=mode) context._add_to_context(*projected_shapes, mode=mode)
if projected_points: if projected_points:
result = ShapeList(projected_points) result = projected_points
else: else:
result = Compound(projected_shapes) result = Compound(projected_shapes)
if all([obj._dim == 2 for obj in object_list]): if all([obj._dim == 2 for obj in object_list]):
@ -825,7 +849,7 @@ def project(
def scale( def scale(
objects: Shape | Iterable[Shape] = None, objects: Shape | Iterable[Shape] | None = None,
by: float | tuple[float, float, float] = 1, by: float | tuple[float, float, float] = 1,
mode: Mode = Mode.REPLACE, mode: Mode = Mode.REPLACE,
) -> Curve | Sketch | Part | Compound: ) -> Curve | Sketch | Part | Compound:
@ -838,14 +862,14 @@ def scale(
line, circle, etc. line, circle, etc.
Args: Args:
objects (Union[Edge, Face, Compound, Solid] or Iterable of): objects to scale objects (Edge | Face | Compound | Solid or Iterable of): objects to scale
by (Union[float, tuple[float, float, float]]): scale factor by (float | tuple[float, float, float]): scale factor
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
Raises: Raises:
ValueError: missing objects ValueError: missing objects
""" """
context: Builder = Builder._get_context("scale") context: Builder | None = Builder._get_context("scale")
if objects is None: if objects is None:
if context is None or context is not None and context._obj 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 len(by) == 3
and all(isinstance(s, (int, float)) for s in by) and all(isinstance(s, (int, float)) for s in by)
): ):
factor = Vector(by) by_vector = Vector(by)
scale_matrix = Matrix( scale_matrix = Matrix(
[ [
[factor.X, 0.0, 0.0, 0.0], [by_vector.X, 0.0, 0.0, 0.0],
[0.0, factor.Y, 0.0, 0.0], [0.0, by_vector.Y, 0.0, 0.0],
[0.0, 0.0, factor.Z, 0.0], [0.0, 0.0, by_vector.Z, 0.0],
[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0],
] ]
) )
@ -877,9 +901,12 @@ def scale(
new_objects = [] new_objects = []
for obj in object_list: for obj in object_list:
if obj is None:
continue
current_location = obj.location current_location = obj.location
assert current_location is not None
obj_at_origin = obj.located(Location(Vector())) 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) new_object = obj_at_origin.scale(factor).locate(current_location)
else: else:
new_object = obj_at_origin.transform_geometry(scale_matrix).locate( new_object = obj_at_origin.transform_geometry(scale_matrix).locate(
@ -900,12 +927,12 @@ def scale(
return scale_compound.unwrap(fully=False) return scale_compound.unwrap(fully=False)
#:TypeVar("SplitType"): Type of objects which can be offset SplitType: TypeAlias = Edge | Wire | Face | Solid
SplitType = Union[Edge, Wire, Face, Solid] """Type of objects which can be split"""
def split( def split(
objects: SplitType | Iterable[SplitType] = None, objects: SplitType | Iterable[SplitType] | None = None,
bisect_by: Plane | Face | Shell = Plane.XZ, bisect_by: Plane | Face | Shell = Plane.XZ,
keep: Keep = Keep.TOP, keep: Keep = Keep.TOP,
mode: Mode = Mode.REPLACE, mode: Mode = Mode.REPLACE,
@ -917,8 +944,8 @@ def split(
Bisect object with plane and keep either top, bottom or both. Bisect object with plane and keep either top, bottom or both.
Args: Args:
objects (Union[Edge, Wire, Face, Solid] or Iterable of), objects to split objects (Edge | Wire | Face | Solid or Iterable of), objects to split
bisect_by (Union[Plane, Face], optional): plane to segment part. bisect_by (Plane | Face, optional): plane to segment part.
Defaults to Plane.XZ. Defaults to Plane.XZ.
keep (Keep, optional): selector for which segment to keep. Defaults to Keep.TOP. keep (Keep, optional): selector for which segment to keep. Defaults to Keep.TOP.
mode (Mode, optional): combination mode. Defaults to Mode.REPLACE. mode (Mode, optional): combination mode. Defaults to Mode.REPLACE.
@ -926,7 +953,7 @@ def split(
Raises: Raises:
ValueError: missing objects ValueError: missing objects
""" """
context: Builder = Builder._get_context("split") context: Builder | None = Builder._get_context("split")
if objects is None: if objects is None:
if context is None or context is not None and context._obj 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) validate_inputs(context, "split", object_list)
new_objects = [] new_objects: list[SplitType] = []
for obj in object_list: for obj in object_list:
bottom = None bottom = None
if keep == Keep.BOTH: if keep == Keep.BOTH:
@ -963,18 +990,18 @@ def split(
return split_compound return split_compound
#:TypeVar("SweepType"): Type of objects which can be swept SweepType: TypeAlias = Compound | Edge | Wire | Face | Solid
SweepType = Union[Compound, Edge, Wire, Face, Solid] """Type of objects which can be swept"""
def sweep( def sweep(
sections: SweepType | Iterable[SweepType] = None, sections: SweepType | Iterable[SweepType] | None = None,
path: Curve | Edge | Wire | Iterable[Edge] = None, path: Curve | Edge | Wire | Iterable[Edge] | None = None,
multisection: bool = False, multisection: bool = False,
is_frenet: bool = False, is_frenet: bool = False,
transition: Transition = Transition.TRANSFORMED, transition: Transition = Transition.TRANSFORMED,
normal: VectorLike = None, normal: VectorLike | None = None,
binormal: Edge | Wire = None, binormal: Edge | Wire | None = None,
clean: bool = True, clean: bool = True,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Part | Sketch: ) -> Part | Sketch:
@ -983,19 +1010,19 @@ def sweep(
Sweep pending 1D or 2D objects along path. Sweep pending 1D or 2D objects along path.
Args: Args:
sections (Union[Compound, Edge, Wire, Face, Solid]): cross sections to sweep into object sections (Compound | Edge | Wire | Face | Solid): cross sections to sweep into object
path (Union[Curve, Edge, Wire], optional): path to follow. path (Curve | Edge | Wire, optional): path to follow.
Defaults to context pending_edges. Defaults to context pending_edges.
multisection (bool, optional): sweep multiple on path. Defaults to False. multisection (bool, optional): sweep multiple on path. Defaults to False.
is_frenet (bool, optional): use frenet algorithm. Defaults to False. is_frenet (bool, optional): use frenet algorithm. Defaults to False.
transition (Transition, optional): discontinuity handling option. transition (Transition, optional): discontinuity handling option.
Defaults to Transition.TRANSFORMED. Defaults to Transition.TRANSFORMED.
normal (VectorLike, optional): fixed normal. Defaults to None. 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. clean (bool, optional): Remove extraneous internal structure. Defaults to True.
mode (Mode, optional): combination. Defaults to Mode.ADD. mode (Mode, optional): combination. Defaults to Mode.ADD.
""" """
context: Builder = Builder._get_context("sweep") context: Builder | None = Builder._get_context("sweep")
section_list = ( section_list = (
[*sections] if isinstance(sections, (list, tuple, filter)) else [sections] [*sections] if isinstance(sections, (list, tuple, filter)) else [sections]
@ -1005,7 +1032,11 @@ def sweep(
validate_inputs(context, "sweep", section_list) validate_inputs(context, "sweep", section_list)
if path is None: 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") raise ValueError("path must be provided")
path_wire = Wire(context.pending_edges) path_wire = Wire(context.pending_edges)
context.pending_edges = [] context.pending_edges = []
@ -1030,8 +1061,8 @@ def sweep(
else: else:
raise ValueError("No sections provided") raise ValueError("No sections provided")
edge_list = [] edge_list: list[Edge] = []
face_list = [] face_list: list[Face] = []
for sec in section_list: for sec in section_list:
if isinstance(sec, (Curve, Wire, Edge)): if isinstance(sec, (Curve, Wire, Edge)):
edge_list.extend(sec.edges()) edge_list.extend(sec.edges())
@ -1040,6 +1071,7 @@ def sweep(
# sweep to create solids # sweep to create solids
new_solids = [] new_solids = []
binormal_mode: Wire | Vector | None
if face_list: if face_list:
if binormal is None and normal is not None: if binormal is None and normal is not None:
binormal_mode = Vector(normal) binormal_mode = Vector(normal)
@ -1066,7 +1098,7 @@ def sweep(
] ]
# sweep to create faces # sweep to create faces
new_faces = [] new_faces: list[Face] = []
if edge_list: if edge_list:
for sec in section_list: for sec in section_list:
swept = Shell.sweep(sec, path_wire, transition) swept = Shell.sweep(sec, path_wire, transition)

View file

@ -28,9 +28,9 @@ license:
""" """
from __future__ import annotations from __future__ import annotations
from typing import Union
from collections.abc import Iterable from collections.abc import Iterable
from scipy.spatial import Voronoi
from build123d.build_enums import Mode, SortBy from build123d.build_enums import Mode, SortBy
from build123d.topology import ( from build123d.topology import (
Compound, Compound,
@ -46,7 +46,6 @@ from build123d.topology import (
from build123d.geometry import Vector, TOLERANCE from build123d.geometry import Vector, TOLERANCE
from build123d.build_common import flatten_sequence, validate_inputs from build123d.build_common import flatten_sequence, validate_inputs
from build123d.build_sketch import BuildSketch from build123d.build_sketch import BuildSketch
from scipy.spatial import Voronoi
def full_round( def full_round(
@ -77,7 +76,7 @@ def full_round(
geometric center of the arc, and the third the radius of the arc 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): if not isinstance(edge, Edge):
raise ValueError("A single Edge must be provided") 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 # Refine the largest empty circle center estimate by averaging the best
# three candidates. The minimum distance between the edges and this # three candidates. The minimum distance between the edges and this
# center is the circle radius. # 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): for i, v in enumerate(voronoi_vertices):
distances = [edge_group[i].distance_to(v) for i in range(3)] 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 # Extract the indices of the best three and average them
best_indices = [x[1] for x in best_three] 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 # Determine where the connected edges intersect with the largest empty circle
connected_edges_end_points = [ connected_edges_end_points = [
@ -142,7 +147,7 @@ def full_round(
for i, e in enumerate(connected_edges) for i, e in enumerate(connected_edges)
] ]
for param in connected_edges_end_params: 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") raise ValueError("Invalid geometry to create the end arc")
common_vertex_points = [ common_vertex_points = [
@ -177,7 +182,14 @@ def full_round(
) )
# Recover other edges # 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 # Rebuild the face
# Note that the longest wire must be the perimeter and others holes # Note that the longest wire must be the perimeter and others holes
@ -195,7 +207,7 @@ def full_round(
def make_face( def make_face(
edges: Edge | Iterable[Edge] = None, mode: Mode = Mode.ADD edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD
) -> Sketch: ) -> Sketch:
"""Sketch Operation: make_face """Sketch Operation: make_face
@ -206,7 +218,7 @@ def make_face(
sketch pending edges. sketch pending edges.
mode (Mode, optional): combination mode. Defaults to Mode.ADD. 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: if edges is not None:
outer_edges = flatten_sequence(edges) outer_edges = flatten_sequence(edges)
@ -230,7 +242,7 @@ def make_face(
def make_hull( def make_hull(
edges: Edge | Iterable[Edge] = None, mode: Mode = Mode.ADD edges: Edge | Iterable[Edge] | None = None, mode: Mode = Mode.ADD
) -> Sketch: ) -> Sketch:
"""Sketch Operation: make_hull """Sketch Operation: make_hull
@ -241,7 +253,7 @@ def make_hull(
sketch pending edges. sketch pending edges.
mode (Mode, optional): combination mode. Defaults to Mode.ADD. 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: if edges is not None:
hull_edges = flatten_sequence(edges) hull_edges = flatten_sequence(edges)
@ -268,7 +280,7 @@ def make_hull(
def trace( 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, line_width: float = 1,
mode: Mode = Mode.ADD, mode: Mode = Mode.ADD,
) -> Sketch: ) -> Sketch:
@ -277,7 +289,7 @@ def trace(
Convert edges, wires or pending edges into faces by sweeping a perpendicular line along them. Convert edges, wires or pending edges into faces by sweeping a perpendicular line along them.
Args: 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. trace. Defaults to sketch pending edges.
line_width (float, optional): Defaults to 1. line_width (float, optional): Defaults to 1.
mode (Mode, optional): combination mode. Defaults to Mode.ADD. mode (Mode, optional): combination mode. Defaults to Mode.ADD.
@ -288,7 +300,7 @@ def trace(
Returns: Returns:
Sketch: Traced lines Sketch: Traced lines
""" """
context: BuildSketch = BuildSketch._get_context("trace") context: BuildSketch | None = BuildSketch._get_context("trace")
if lines is not None: if lines is not None:
trace_lines = flatten_sequence(lines) trace_lines = flatten_sequence(lines)
@ -298,7 +310,7 @@ def trace(
else: else:
raise ValueError("No objects to trace") raise ValueError("No objects to trace")
new_faces = [] new_faces: list[Face] = []
for edge in trace_edges: for edge in trace_edges:
trace_pen = edge.perpendicular_line(line_width, 0) trace_pen = edge.perpendicular_line(line_width, 0)
new_faces.extend(Face.sweep(trace_pen, edge).faces()) new_faces.extend(Face.sweep(trace_pen, edge).faces())
@ -306,6 +318,7 @@ def trace(
context._add_to_context(*new_faces, mode=mode) context._add_to_context(*new_faces, mode=mode)
context.pending_edges = ShapeList() 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] combined_faces = Face.fuse(*new_faces) if len(new_faces) > 1 else new_faces[0]
result = ( result = (
Sketch(combined_faces) Sketch(combined_faces)

View file

@ -237,7 +237,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
font: str = "Arial", font: str = "Arial",
font_path: str | None = None, font_path: str | None = None,
font_style: FontStyle = FontStyle.REGULAR, 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, position_on_path: float = 0.0,
text_path: Edge | Wire | None = None, text_path: Edge | Wire | None = None,
) -> Compound: ) -> Compound:

View file

@ -29,6 +29,7 @@ from build123d import (
add, add,
mirror, mirror,
section, section,
ThreePointArc,
) )
from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType
@ -173,6 +174,24 @@ class ExportersTestCase(unittest.TestCase):
svg.add_shape(sketch) svg.add_shape(sketch)
svg.write("test-colors.svg") 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( @pytest.mark.parametrize(
"format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"] "format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"]