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.*]
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

View file

@ -243,7 +243,7 @@ class Builder(ABC):
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] = []

View file

@ -29,7 +29,7 @@ license:
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 @@ class Arrow(BaseSketchObject):
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 @@ class Draft:
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 @@ class Draft:
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 @@ class DimensionLine(BaseSketchObject):
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 @@ class ExtensionLine(BaseSketchObject):
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 @@ class ExtensionLine(BaseSketchObject):
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 @@ class TechnicalDrawing(BaseSketchObject):
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 @@ class TechnicalDrawing(BaseSketchObject):
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

View file

@ -34,9 +34,9 @@ import math
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 @@ import svgpathtools as PT
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.topology import (
)
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,
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 @@ class ExportDXF(Export2D):
"""
# ezdxf :doc:`line type <ezdxf-stable:concepts/linetypes>`.
kwargs = {}
kwargs: dict[str, Any] = {}
if line_type is not None:
linetype = self._linetype(line_type)
@ -587,7 +587,7 @@ class ExportDXF(Export2D):
# 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 @@ class ExportDXF(Export2D):
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 @@ class ExportDXF(Export2D):
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 @@ class ExportDXF(Export2D):
)
# 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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
"""
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
}
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 @@ class ExportSVG(Export2D):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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 @@ class ExportSVG(Export2D):
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)

View file

@ -30,12 +30,11 @@ from __future__ import annotations
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 @@ class BasePartObject(Part):
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 @@ class BasePartObject(Part):
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 @@ class Box(BasePartObject):
),
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 @@ class Cone(BasePartObject):
),
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 @@ class CounterBoreHole(BasePartObject):
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 @@ class CounterBoreHole(BasePartObject):
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 @@ class CounterBoreHole(BasePartObject):
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 @@ class CounterSinkHole(BasePartObject):
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 @@ class CounterSinkHole(BasePartObject):
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 @@ class CounterSinkHole(BasePartObject):
),
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 @@ class Cylinder(BasePartObject):
),
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 @@ class Sphere(BasePartObject):
),
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 @@ class Torus(BasePartObject):
),
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 @@ class Wedge(BasePartObject):
),
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]]):

View file

@ -31,7 +31,7 @@ from __future__ import annotations
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 @@ class BaseSketchObject(Sketch):
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 @@ class BaseSketchObject(Sketch):
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 @@ class Circle(BaseSketchObject):
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 @@ class Ellipse(BaseSketchObject):
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 @@ class Polygon(BaseSketchObject):
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 @@ class Rectangle(BaseSketchObject):
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 @@ class RectangleRounded(BaseSketchObject):
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 @@ class RegularPolygon(BaseSketchObject):
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 @@ class SlotArc(BaseSketchObject):
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 @@ class SlotCenterPoint(BaseSketchObject):
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 @@ class SlotCenterToCenter(BaseSketchObject):
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 @@ class SlotOverall(BaseSketchObject):
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 @@ class SlotOverall(BaseSketchObject):
).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 @@ class Text(BaseSketchObject):
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 @@ class Trapezoid(BaseSketchObject):
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 @@ class Triangle(BaseSketchObject):
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 [

View file

@ -30,7 +30,7 @@ license:
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 @@ from build123d.topology import (
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)

View file

@ -28,9 +28,9 @@ license:
"""
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.topology import (
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)

View file

@ -237,7 +237,7 @@ class Compound(Mixin3D, Shape[TopoDS_Compound]):
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:

View file

@ -29,6 +29,7 @@ from build123d import (
add,
mirror,
section,
ThreePointArc,
)
from build123d.exporters import ExportSVG, ExportDXF, Drawing, LineType
@ -173,6 +174,24 @@ class ExportersTestCase(unittest.TestCase):
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"]