build123d/src/build123d/topology/one_d.py
2025-11-14 14:41:37 -05:00

4267 lines
154 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
build123d topology
name: one_d.py
by: Gumyr
date: January 07, 2025
desc:
This module defines the classes and methods for one-dimensional geometric entities in the build123d
CAD library. It focuses on `Edge` and `Wire`, representing essential topological elements like
curves and connected sequences of curves within a 3D model. These entities are pivotal for
constructing complex shapes, boundaries, and paths in CAD applications.
Key Features:
- **Edge Class**:
- Represents curves such as lines, arcs, splines, and circles.
- Supports advanced operations like trimming, offsetting, splitting, and projecting onto shapes.
- Includes methods for geometric queries like finding tangent angles, normals, and intersection
points.
- **Wire Class**:
- Represents a connected sequence of edges forming a continuous path.
- Supports operations such as closure, projection, and edge manipulation.
- **Mixin1D**:
- Shared functionality for both `Edge` and `Wire` classes, enabling splitting, extrusion, and
1D-specific operations.
This module integrates deeply with OpenCascade, leveraging its robust geometric and topological
operations. It provides utility functions to create, manipulate, and query 1D geometric entities,
ensuring precise and efficient workflows in 3D modeling tasks.
license:
Copyright 2025 Gumyr
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from __future__ import annotations
import copy
import warnings
from collections.abc import Iterable, Sequence
from itertools import combinations
from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians
from typing import TYPE_CHECKING, Literal, overload
from typing import cast as tcast
import numpy as np
import OCP.TopAbs as ta
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_CompCurve, BRepAdaptor_Curve
from OCP.BRepAlgoAPI import (
BRepAlgoAPI_Common,
BRepAlgoAPI_Section,
BRepAlgoAPI_Splitter,
)
from OCP.BRepBuilderAPI import (
BRepBuilderAPI_DisconnectedWire,
BRepBuilderAPI_EmptyWire,
BRepBuilderAPI_MakeEdge,
BRepBuilderAPI_MakeEdge2d,
BRepBuilderAPI_MakeFace,
BRepBuilderAPI_MakePolygon,
BRepBuilderAPI_MakeWire,
BRepBuilderAPI_NonManifoldWire,
)
from OCP.BRepExtrema import BRepExtrema_DistShapeShape, BRepExtrema_SupportType
from OCP.BRepFilletAPI import BRepFilletAPI_MakeFillet2d
from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepLib import BRepLib, BRepLib_FindSurface
from OCP.BRepLProp import BRepLProp
from OCP.BRepOffset import BRepOffset_MakeOffset
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepProj import BRepProj_Projection
from OCP.BRepTools import BRepTools, BRepTools_WireExplorer
from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse
from OCP.GccEnt import GccEnt_unqualified, GccEnt_Position
from OCP.GCPnts import GCPnts_AbscissaPoint
from OCP.Geom import (
Geom_BezierCurve,
Geom_BSplineCurve,
Geom_ConicalSurface,
Geom_CylindricalSurface,
Geom_Line,
Geom_Plane,
Geom_Surface,
Geom_TrimmedCurve,
)
from OCP.Geom2d import (
Geom2d_CartesianPoint,
Geom2d_Circle,
Geom2d_Curve,
Geom2d_Line,
Geom2d_Point,
Geom2d_TrimmedCurve,
)
from OCP.Geom2dAdaptor import Geom2dAdaptor_Curve
from OCP.Geom2dAPI import Geom2dAPI_InterCurveCurve
from OCP.Geom2dGcc import Geom2dGcc_Circ2d2TanRad, Geom2dGcc_QualifiedCurve
from OCP.GeomAbs import (
GeomAbs_C0,
GeomAbs_C1,
GeomAbs_C2,
GeomAbs_G1,
GeomAbs_G2,
GeomAbs_JoinType,
)
from OCP.GeomAdaptor import GeomAdaptor_Curve
from OCP.GeomAPI import (
GeomAPI,
GeomAPI_IntCS,
GeomAPI_Interpolate,
GeomAPI_PointsToBSpline,
GeomAPI_ProjectPointOnCurve,
)
from OCP.GeomConvert import GeomConvert_CompCurveToBSplineCurve
from OCP.GeomFill import (
GeomFill_CorrectedFrenet,
GeomFill_Frenet,
GeomFill_TrihedronLaw,
)
from OCP.GeomProjLib import GeomProjLib
from OCP.gp import (
gp_Ax1,
gp_Ax2,
gp_Ax3,
gp_Circ,
gp_Circ2d,
gp_Dir,
gp_Dir2d,
gp_Elips,
gp_Pln,
gp_Pnt,
gp_Pnt2d,
gp_Trsf,
gp_Vec,
)
from OCP.GProp import GProp_GProps
from OCP.HLRAlgo import HLRAlgo_Projector
from OCP.HLRBRep import HLRBRep_Algo, HLRBRep_HLRToShape
from OCP.ShapeAnalysis import ShapeAnalysis_FreeBounds
from OCP.ShapeFix import ShapeFix_Shape, ShapeFix_Wireframe
from OCP.Standard import (
Standard_ConstructionError,
Standard_Failure,
Standard_NoSuchObject,
)
from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt
from OCP.TColStd import (
TColStd_Array1OfReal,
TColStd_HArray1OfBoolean,
TColStd_HArray1OfReal,
)
from OCP.TopAbs import TopAbs_Orientation, TopAbs_ShapeEnum
from OCP.TopExp import TopExp, TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import (
TopoDS,
TopoDS_Compound,
TopoDS_Edge,
TopoDS_Face,
TopoDS_Shape,
TopoDS_Shell,
TopoDS_Vertex,
TopoDS_Wire,
)
from OCP.TopTools import (
TopTools_HSequenceOfShape,
TopTools_IndexedDataMapOfShapeListOfShape,
TopTools_IndexedMapOfShape,
TopTools_ListOfShape,
)
from scipy.optimize import minimize_scalar
from scipy.spatial import ConvexHull
from typing_extensions import Self
from build123d.build_enums import (
AngularDirection,
CenterOf,
ContinuityLevel,
FrameMethod,
GeomType,
Keep,
Kind,
Sagitta,
Tangency,
PositionMode,
Side,
)
from build123d.geometry import (
DEG2RAD,
TOL_DIGITS,
TOLERANCE,
Axis,
Color,
Location,
Plane,
Vector,
VectorLike,
logger,
)
from .shape_core import (
TOPODS,
Shape,
ShapeList,
SkipClean,
TrimmingTool,
downcast,
get_top_level_topods_shapes,
shapetype,
topods_dim,
unwrap_topods_compound,
)
from .utils import (
_extrude_topods_shape,
_make_topods_face_from_wires,
_topods_bool_op,
isclose_b,
)
from .zero_d import Vertex, topo_explore_common_vertex
from .constrained_lines import (
_make_2tan_rad_arcs,
_make_2tan_on_arcs,
_make_3tan_arcs,
_make_tan_cen_arcs,
_make_tan_on_rad_arcs,
_make_tan_oriented_lines,
_make_2tan_lines,
)
if TYPE_CHECKING: # pragma: no cover
from .composite import Compound, Curve, Part, Sketch # pylint: disable=R0801
from .three_d import Solid # pylint: disable=R0801
from .two_d import Face, Shell # pylint: disable=R0801
class Mixin1D(Shape[TOPODS]):
"""Methods to add to the Edge and Wire classes"""
# ---- Properties ----
@property
def _dim(self) -> int:
"""Dimension of Edges and Wires"""
return 1
@property
def is_closed(self) -> bool:
"""Are the start and end points equal?"""
if self._wrapped is None:
raise ValueError("Can't determine if empty Edge or Wire is closed")
return BRep_Tool.IsClosed_s(self.wrapped)
@property
def is_forward(self) -> bool:
"""Does the Edge/Wire loop forward or reverse"""
if self._wrapped is None:
raise ValueError("Can't determine direction of empty Edge or Wire")
return self.wrapped.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
@property
def is_interior(self) -> bool:
"""
Check if the edge is an interior edge.
An interior edge lies between surfaces that are part of the body (internal
to the geometry) and does not form part of the exterior boundary.
Returns:
bool: True if the edge is an interior edge, False otherwise.
"""
# Find the faces connected to this edge and offset them
topods_face_pair = topo_explore_connected_faces(self)
offset_face_pair = [
offset_topods_face(f, self.length / 100) for f in topods_face_pair
]
# Intersect the offset faces
sectionor = BRepAlgoAPI_Section(
offset_face_pair[0], offset_face_pair[1], PerformNow=False
)
sectionor.Build()
face_intersection_result = sectionor.Shape()
# If an edge was created the faces intersect and the edge is interior
explorer = TopExp_Explorer(face_intersection_result, ta.TopAbs_EDGE)
return explorer.More()
@property
def length(self) -> float:
"""Edge or Wire length"""
return GCPnts_AbscissaPoint.Length_s(self.geom_adaptor())
@property
def radius(self) -> float:
"""Calculate the radius.
Note that when applied to a Wire, the radius is simply the radius of the first edge.
Args:
Returns:
radius
Raises:
ValueError: if kernel can not reduce the shape to a circular edge
"""
geom = self.geom_adaptor()
try:
circ = geom.Circle()
except (Standard_NoSuchObject, Standard_Failure) as err:
raise ValueError("Shape could not be reduced to a circle") from err
return circ.Radius()
@property
def volume(self) -> float:
"""volume - the volume of this Edge or Wire, which is always zero"""
return 0.0
# ---- Class Methods ----
@classmethod
def cast(cls, obj: TopoDS_Shape) -> Vertex | Edge | Wire:
"Returns the right type of wrapper, given a OCCT object"
# Extend the lookup table with additional entries
constructor_lut = {
ta.TopAbs_VERTEX: Vertex,
ta.TopAbs_EDGE: Edge,
ta.TopAbs_WIRE: Wire,
}
shape_type = shapetype(obj)
# NB downcast is needed to handle TopoDS_Shape types
return constructor_lut[shape_type](downcast(obj))
@classmethod
def extrude(
cls, obj: Shape, direction: VectorLike
) -> Edge | Face | Shell | Solid | Compound:
"""Unused - only here because Mixin1D is a subclass of Shape"""
return NotImplemented
# ---- Static Methods ----
@staticmethod
def _to_param(edge_wire: Mixin1D, value: float | VectorLike, name: str) -> float:
"""Convert a float or VectorLike into a curve parameter."""
if isinstance(value, (int, float)):
return float(value)
try:
point = Vector(value)
except TypeError as exc:
raise TypeError(
f"{name} must be a float or VectorLike, not {value!r}"
) from exc
return edge_wire.param_at_point(point)
# ---- Instance Methods ----
def __add__(
self, other: None | Shape | Iterable[Shape]
) -> Edge | Wire | ShapeList[Edge]:
"""fuse shape to wire/edge operator +"""
# Convert `other` to list of base topods objects and filter out None values
if other is None:
topods_summands = []
else:
topods_summands = [
shape
# for o in (other if isinstance(other, (list, tuple)) else [other])
for o in ([other] if isinstance(other, Shape) else other)
for shape in get_top_level_topods_shapes(o.wrapped if o else None)
]
# If there is nothing to add return the original object
if not topods_summands:
return self
if not all(topods_dim(summand) == 1 for summand in topods_summands):
raise ValueError("Only shapes with the same dimension can be added")
# Convert back to Edge/Wire objects now that it's safe to do so
summands = ShapeList(
[tcast(Edge | Wire, Mixin1D.cast(s)) for s in topods_summands]
)
summand_edges = [e for summand in summands for e in summand.edges()]
if self._wrapped is None: # an empty object
if len(summands) == 1:
sum_shape: Edge | Wire | ShapeList[Edge] = summands[0]
else:
try:
sum_shape = Wire(summand_edges)
except Exception:
# pylint: disable=[no-member]
sum_shape = summands[0].fuse(*summands[1:])
if type(self).order == 4:
sum_shape = type(self)(sum_shape) # type: ignore
else:
try:
sum_shape = Wire(self.edges() + ShapeList(summand_edges))
except Exception:
sum_shape = self.fuse(*summands)
if SkipClean.clean and not isinstance(sum_shape, list):
sum_shape = sum_shape.clean()
# If there is only one Edge, return that
sum_shape = sum_shape.edge() if len(sum_shape.edges()) == 1 else sum_shape # type: ignore
return sum_shape
def __matmul__(self, position: float) -> Vector:
"""Position on wire operator @"""
return self.position_at(position)
def __mod__(self, position: float) -> Vector:
"""Tangent on wire operator %"""
return self.tangent_at(position)
def __xor__(self, position: float) -> Location:
"""Location on wire operator ^"""
return self.location_at(position)
def center(self, center_of: CenterOf = CenterOf.GEOMETRY) -> Vector:
"""Center of object
Return the center based on center_of
Args:
center_of (CenterOf, optional): centering option. Defaults to CenterOf.GEOMETRY.
Returns:
Vector: center
"""
if self._wrapped is None:
raise ValueError("Can't find center of empty edge/wire")
if center_of == CenterOf.GEOMETRY:
middle = self.position_at(0.5)
elif center_of == CenterOf.MASS:
properties = GProp_GProps()
BRepGProp.LinearProperties_s(self.wrapped, properties)
middle = Vector(properties.CentreOfMass())
else: # center_of == CenterOf.BOUNDING_BOX:
middle = self.bounding_box().center()
return middle
def common_plane(self, *lines: Edge | Wire | None) -> None | Plane:
"""common_plane
Find the plane containing all the edges/wires (including self). If there
is no common plane return None. If the edges are coaxial, select one
of the infinite number of valid planes.
Args:
lines (sequence of Edge | Wire): edges in common with self
Returns:
None | Plane: Either the common plane or None
"""
# pylint: disable=too-many-locals
# Note: BRepLib_FindSurface is not helpful as it requires the
# Edges to form a surface perimeter.
points: list[Vector] = []
all_lines: list[Edge | Wire] = [
line for line in [self, *lines] if line is not None
]
if any(not isinstance(line, (Edge, Wire)) for line in all_lines):
raise ValueError("Only Edges or Wires are valid")
result = None
# Are they all co-axial - if so, select one of the infinite planes
all_edges: list[Edge] = [e for l in all_lines for e in l.edges()]
if all(e.geom_type == GeomType.LINE for e in all_edges):
as_axis = [Axis(e @ 0, e % 0) for e in all_edges]
if all(a0.is_coaxial(a1) for a0, a1 in combinations(as_axis, 2)):
origin = as_axis[0].position
x_dir = as_axis[0].direction
z_dir = Plane(as_axis[0]).x_dir
c_plane = Plane(origin, z_dir=z_dir)
result = c_plane.shift_origin((0, 0))
if result is None: # not coaxial
# Shorten any infinite lines (from converted Axis)
normal_lines = list(filter(lambda line: line.length <= 1e50, all_lines))
infinite_lines = filter(lambda line: line.length > 1e50, all_lines)
shortened_lines = [l.trim_to_length(0.5, 10) for l in infinite_lines]
all_lines = normal_lines + shortened_lines
for line in all_lines:
num_points = 2 if line.geom_type == GeomType.LINE else 8
points.extend(
[line.position_at(i / (num_points - 1)) for i in range(num_points)]
)
points = list(set(points)) # unique points
extreme_areas = {}
for subset in combinations(points, 3):
vector1 = subset[1] - subset[0]
vector2 = subset[2] - subset[0]
area = 0.5 * (vector1.cross(vector2).length)
extreme_areas[area] = subset
# The points that create the largest area make the most accurate plane
extremes = extreme_areas[sorted(list(extreme_areas.keys()))[-1]]
# Create a plane from these points
x_dir = (extremes[1] - extremes[0]).normalized()
z_dir = (extremes[2] - extremes[0]).cross(x_dir)
try:
c_plane = Plane(
origin=(sum(extremes, Vector(0, 0, 0)) / 3), z_dir=z_dir
)
c_plane = c_plane.shift_origin((0, 0))
except ValueError:
# There is no valid common plane
result = None
else:
# Are all of the points on the common plane
common = all(c_plane.contains(p) for p in points)
result = c_plane if common else None
return result
def curvature_comb(
self, count: int = 100, max_tooth_size: float | None = None
) -> ShapeList[Edge]:
"""
Build a *curvature comb* for a planar (XY) 1D curve.
A curvature comb is a set of short line segments (“teeth”) erected
perpendicular to the curve that visualize the signed curvature κ(u).
Tooth length is proportional to |κ| and the direction encodes the sign
(left normal for κ>0, right normal for κ<0). This is useful for inspecting
fairness and continuity (C0/C1/C2) of edges and wires.
Args:
count (int, optional): Number of uniformly spaced samples over the normalized
parameter. Increase for a denser comb. Defaults to 100.
max_tooth_size (float | None, optional): Maximum tooth height in model units.
If None, set to 10% maximum curve dimension. Defaults to None.
Raises:
ValueError: Empty curve.
ValueError: If the curve is not planar on `Plane.XY`.
Returns:
ShapeList[Edge]: A list of short `Edge` objects (lines) anchored on the curve
and oriented along the left normal `n̂ = normalize(t) × +Z`.
Notes:
- On circles, κ = 1/R so tooth length is constant.
- On straight segments, κ = 0 so no teeth are drawn.
- At inflection points κ→0 and the tooth flips direction.
- At C0 corners the tangent is discontinuous; nearby teeth may jump.
C1 yields continuous direction; C2 yields continuous magnitude as well.
Example:
>>> comb = my_wire.curvature_comb(count=200, max_tooth_size=2.0)
>>> show(my_wire, Curve(comb))
"""
if self._wrapped is None:
raise ValueError("Can't create curvature_comb for empty curve")
pln = self.common_plane()
if pln is None or not isclose(abs(pln.z_dir.Z), 1.0, abs_tol=TOLERANCE):
raise ValueError("curvature_comb only works for curves on Plane.XY")
# If periodic the first and last tooth would be the same so skip them
u_values = np.linspace(0, 1, count, endpoint=not self.is_closed)
# first pass: gather kappas for scaling
kappas = []
tangents, curvatures = [], []
for u in u_values:
tangent = self.derivative_at(u, 1)
curvature = self.derivative_at(u, 2)
tangents.append(tangent)
curvatures.append(curvature)
cross = tangent.cross(curvature)
kappa = cross.length / (tangent.length**3 + TOLERANCE)
# signed for XY:
sign = 1.0 if cross.Z >= 0 else -1.0
kappas.append(sign * kappa)
# choose a scale so the tallest tooth is max_tooth_size
max_kappa_size = max(TOLERANCE, max(abs(k) for k in kappas))
curve_size = max(self.bounding_box().size)
max_tooth_size = (
max_tooth_size if max_tooth_size is not None else curve_size / 10
)
scale = max_tooth_size / max_kappa_size
comb_edges = ShapeList[Edge]()
for u, kappa, tangent in zip(u_values, kappas, tangents):
# Avoid tiny teeth
if abs(length := scale * kappa) < TOLERANCE:
continue
pnt_on_curve = self @ u
# left normal in XY (principal normal direction for a planar curve)
kappa_dir = tangent.normalized().cross(Vector(0, 0, 1))
comb_edges.append(
Edge.make_line(pnt_on_curve, pnt_on_curve + length * kappa_dir)
)
return comb_edges
def derivative_at(
self,
position: float | VectorLike,
order: int = 2,
position_mode: PositionMode = PositionMode.PARAMETER,
) -> Vector:
"""Derivative At
Generate a derivative along the underlying curve.
Args:
position (float | VectorLike): distance, parameter value or point
order (int): derivative order. Defaults to 2
position_mode (PositionMode, optional): position calculation mode. Defaults to
PositionMode.PARAMETER.
Raises:
ValueError: position must be a float or a point
Returns:
Vector: position on the underlying curve
"""
if isinstance(position, (float, int)):
comp_curve, occt_param, closest_forward = self._occt_param_at(
position, position_mode
)
else:
try:
point_on_curve = Vector(position)
except Exception as exc:
raise ValueError("position must be a float or a point") from exc
if isinstance(self, Wire):
closest = min(self.edges(), key=lambda e: e.distance_to(point_on_curve))
else:
closest = self
u_value = closest.param_at_point(point_on_curve)
comp_curve, occt_param, closest_forward = closest._occt_param_at(u_value)
derivative_gp_vec = comp_curve.DN(occt_param, order)
if derivative_gp_vec.Magnitude() == 0:
return Vector(0, 0, 0)
derivative = Vector(derivative_gp_vec)
# Potentially flip the direction of the derivative
if order % 2 == 1:
if isinstance(self, Wire):
edge_same_as_wire = closest_forward == self.is_forward
derivative = derivative if edge_same_as_wire else -derivative
else:
derivative = derivative if self.is_forward else -derivative
return derivative
def edge(self) -> Edge | None:
"""Return the Edge"""
return Shape.get_single_shape(self, "Edge")
def edges(self) -> ShapeList[Edge]:
"""edges - all the edges in this Shape"""
if isinstance(self, Wire) and self.wrapped is not None:
# The WireExplorer is a tool to explore the edges of a wire in a connection order.
explorer = BRepTools_WireExplorer(self.wrapped)
edge_list: ShapeList[Edge] = ShapeList()
while explorer.More():
next_edge = Edge(explorer.Current())
next_edge.topo_parent = (
self if self.topo_parent is None else self.topo_parent
)
edge_list.append(next_edge)
explorer.Next()
return edge_list
edge_list = Shape.get_shape_list(self, "Edge")
return edge_list.filter_by(
lambda e: BRep_Tool.Degenerated_s(e.wrapped), reverse=True
)
def end_point(self) -> Vector:
"""The end point of this edge.
Note that circles may have identical start and end points.
"""
curve = self.geom_adaptor()
umax = curve.LastParameter() if self.is_forward else curve.FirstParameter()
return Vector(curve.Value(umax))
def intersect(
self, *to_intersect: Shape | Vector | Location | Axis | Plane
) -> None | ShapeList[Vertex | Edge]:
"""Intersect Edge with Shape or geometry object
Args:
to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect
Returns:
ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges
"""
def to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
def bool_op(
args: Sequence,
tools: Sequence,
operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common,
) -> ShapeList:
# Wrap Shape._bool_op for corrected output
intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation)
if isinstance(intersections, ShapeList):
return intersections or ShapeList()
if isinstance(intersections, Shape) and not intersections.is_null:
return ShapeList([intersections])
return ShapeList()
def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList:
# Remove lower order shapes from list which *appear* to be part of
# a higher order shape using a lazy distance check
# (sufficient for vertices, may be an issue for higher orders)
order_groups = []
for order in orders:
order_groups.append(
ShapeList([s for s in shapes if isinstance(s, order)])
)
filtered_shapes = order_groups[-1]
for i in range(len(order_groups) - 1):
los = order_groups[i]
his: list = sum(order_groups[i + 1 :], [])
filtered_shapes.extend(
ShapeList(
lo
for lo in los
if all(lo.distance_to(hi) > TOLERANCE for hi in his)
)
)
return filtered_shapes
common_set: ShapeList[Vertex | Edge | Wire] = ShapeList([self])
target: Shape | Plane
for other in to_intersect:
# Conform target type
match other:
case Axis():
# BRepAlgoAPI_Section seems happier if Edge isnt infinite
bbox = self.bounding_box()
dist = self.distance_to(other.position)
dist = dist if dist >= 1 else 1
target = Edge.make_line(
other.position - other.direction * bbox.diagonal * dist,
other.position + other.direction * bbox.diagonal * dist,
)
case Plane():
target = other
case Vector():
target = Vertex(other)
case Location():
target = Vertex(other.position)
case _ if issubclass(type(other), Shape):
target = other
case _:
raise ValueError(f"Unsupported type to_intersect: {type(other)}")
# Find common matches
common: list[Vertex | Edge | Wire] = []
result: ShapeList | None
for obj in common_set:
match (obj, target):
case (_, Plane()):
target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face())
operation = BRepAlgoAPI_Section()
result = bool_op((obj,), (target,), operation)
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case (_, Vertex() | Edge() | Wire()):
operation = BRepAlgoAPI_Section()
section = bool_op((obj,), (target,), operation)
result = section
if not section:
operation = BRepAlgoAPI_Common()
result.extend(bool_op((obj,), (target,), operation))
case _ if issubclass(type(target), Shape):
result = target.intersect(obj)
if result:
common.extend(result)
if common:
common_set = ShapeList()
for shape in common:
if isinstance(shape, Wire):
common_set.extend(shape.edges())
else:
common_set.append(shape)
common_set = to_vertex(set(to_vector(common_set)))
common_set = filter_shapes_by_order(common_set, [Vertex, Edge])
else:
return None
return ShapeList(common_set)
def location_at(
self,
distance: float,
position_mode: PositionMode = PositionMode.PARAMETER,
frame_method: FrameMethod = FrameMethod.FRENET,
planar: bool | None = None,
x_dir: VectorLike | None = None,
) -> Location:
"""Locations along curve
Generate a location along the underlying curve.
Args:
distance (float): distance or parameter value
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
frame_method (FrameMethod, optional): moving frame calculation method.
The FRENET frame can “twist” or flip unexpectedly, especially near flat
spots. The CORRECTED frame behaves more like a “camera dolly” or
sweep profile would — it's smoother and more stable.
Defaults to FrameMethod.FRENET.
planar (bool, optional): planar mode. Defaults to None.
x_dir (VectorLike, optional): override the x_dir to help with plane
creation along a 1D shape. Must be perpendicalar to shapes tangent.
Defaults to None.
.. deprecated::
The `planar` parameter is deprecated and will be removed in a future release.
Use `x_dir` to specify orientation instead.
Returns:
Location: A Location object representing local coordinate system
at the specified distance.
"""
curve = self.geom_adaptor()
if not self.is_forward:
if position_mode == PositionMode.PARAMETER:
distance = 1 - distance
else:
distance = self.length - distance
if position_mode == PositionMode.PARAMETER:
param = self.param_at(distance)
else:
param = self.param_at(distance / self.length)
law: GeomFill_TrihedronLaw
if frame_method == FrameMethod.FRENET:
law = GeomFill_Frenet()
else:
law = GeomFill_CorrectedFrenet()
law.SetCurve(curve)
tangent, normal, binormal = gp_Vec(), gp_Vec(), gp_Vec()
law.D0(param, tangent, normal, binormal)
pnt = curve.Value(param)
transformation = gp_Trsf()
if planar is not None:
warnings.warn(
"The 'planar' parameter is deprecated and will be removed in a future version. "
"Use 'x_dir' to control orientation instead.",
DeprecationWarning,
stacklevel=2,
)
if planar is not None and planar:
transformation.SetTransformation(
gp_Ax3(pnt, gp_Dir(0, 0, 1), gp_Dir(normal.XYZ())), gp_Ax3()
)
elif x_dir is not None:
try:
transformation.SetTransformation(
gp_Ax3(pnt, gp_Dir(tangent.XYZ()), Vector(x_dir).to_dir()), gp_Ax3()
)
except Standard_ConstructionError as exc:
raise ValueError(
f"Unable to create location with given x_dir {x_dir}. "
f"x_dir must be perpendicular to shape's tangent "
f"{tuple(Vector(tangent))}."
) from exc
else:
transformation.SetTransformation(
gp_Ax3(pnt, gp_Dir(tangent.XYZ()), gp_Dir(normal.XYZ())), gp_Ax3()
)
loc = Location(TopLoc_Location(transformation))
if self.is_forward:
return loc
return -loc
def locations(
self,
distances: Iterable[float],
position_mode: PositionMode = PositionMode.PARAMETER,
frame_method: FrameMethod = FrameMethod.FRENET,
planar: bool | None = None,
x_dir: VectorLike | None = None,
) -> list[Location]:
"""Locations along curve
Generate location along the curve
Args:
distances (Iterable[float]): distance or parameter values
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
frame_method (FrameMethod, optional): moving frame calculation method.
Defaults to FrameMethod.FRENET.
planar (bool, optional): planar mode. Defaults to False.
x_dir (VectorLike, optional): override the x_dir to help with plane
creation along a 1D shape. Must be perpendicalar to shapes tangent.
Defaults to None.
.. deprecated::
The `planar` parameter is deprecated and will be removed in a future release.
Use `x_dir` to specify orientation instead.
Returns:
list[Location]: A list of Location objects representing local coordinate
systems at the specified distances.
"""
return [
self.location_at(d, position_mode, frame_method, planar, x_dir)
for d in distances
]
def normal(self) -> Vector:
"""Calculate the normal Vector. Only possible for planar curves.
:return: normal vector
Args:
Returns:
"""
if self._wrapped is None:
raise ValueError("Can't find normal of empty edge/wire")
curve = self.geom_adaptor()
gtype = self.geom_type
if gtype == GeomType.CIRCLE:
circ = curve.Circle()
return_value = Vector(circ.Axis().Direction())
elif gtype == GeomType.ELLIPSE:
ell = curve.Ellipse()
return_value = Vector(ell.Axis().Direction())
else:
find_surface = BRepLib_FindSurface(self.wrapped, OnlyPlane=True)
surf = find_surface.Surface()
if isinstance(surf, Geom_Plane):
pln = surf.Pln()
return_value = Vector(pln.Axis().Direction())
else:
raise ValueError("Normal not defined")
return return_value
def offset_2d(
self,
distance: float,
kind: Kind = Kind.ARC,
side: Side = Side.BOTH,
closed: bool = True,
) -> Edge | Wire:
"""2d Offset
Offsets a planar edge/wire
Args:
distance (float): distance from edge/wire to offset
kind (Kind, optional): offset corner transition. Defaults to Kind.ARC.
side (Side, optional): side to place offset. Defaults to Side.BOTH.
closed (bool, optional): if Side!=BOTH, close the LEFT or RIGHT
offset. Defaults to True.
Raises:
RuntimeError: Multiple Wires generated
RuntimeError: Unexpected result type
Returns:
Wire: offset wire
"""
# pylint: disable=too-many-branches, too-many-locals, too-many-statements
kind_dict = {
Kind.ARC: GeomAbs_JoinType.GeomAbs_Arc,
Kind.INTERSECTION: GeomAbs_JoinType.GeomAbs_Intersection,
Kind.TANGENT: GeomAbs_JoinType.GeomAbs_Tangent,
}
line = self if isinstance(self, Wire) else Wire([self])
# Avoiding a bug when the wire contains a single Edge
if len(line.edges()) == 1:
edge = line.edges()[0]
# pylint: disable=[no-member]
edges = [edge.trim(0.0, 0.5), edge.trim(0.5, 1.0)]
topods_wire = Wire(edges).wrapped
else:
topods_wire = line.wrapped
assert topods_wire is not None
offset_builder = BRepOffsetAPI_MakeOffset()
offset_builder.Init(kind_dict[kind])
# offset_builder.SetApprox(True)
offset_builder.AddWire(topods_wire)
offset_builder.Perform(distance)
obj = downcast(offset_builder.Shape())
if isinstance(obj, TopoDS_Compound):
obj = unwrap_topods_compound(obj, fully=True)
if isinstance(obj, TopoDS_Wire):
offset_wire = Wire(obj)
else: # Likely multiple Wires were generated
raise RuntimeError("Unexpected result type")
if side != Side.BOTH:
# Find and remove the end arcs
endpoints = (line.position_at(0), line.position_at(1))
offset_edges = offset_wire.edges().filter_by(
lambda e: (
e.geom_type == GeomType.CIRCLE
and any((e.arc_center - pt).length < TOLERANCE for pt in endpoints)
),
reverse=True,
)
wires = edges_to_wires(offset_edges)
centers = [w.position_at(0.5) for w in wires]
angles = [
line.tangent_at(0).get_signed_angle(c - line.position_at(0))
for c in centers
]
if side == Side.LEFT:
offset_wire = wires[int(angles[0] > angles[1])]
else:
offset_wire = wires[int(angles[0] <= angles[1])]
if closed:
self0 = line.position_at(0)
self1 = line.position_at(1)
end0 = offset_wire.position_at(0)
end1 = offset_wire.position_at(1)
if (self0 - end0).length - abs(distance) <= TOLERANCE:
edge0 = Edge.make_line(self0, end0)
edge1 = Edge.make_line(self1, end1)
else:
edge0 = Edge.make_line(self0, end1)
edge1 = Edge.make_line(self1, end0)
offset_wire = Wire(
line.edges() + offset_wire.edges() + ShapeList([edge0, edge1])
)
offset_edges = offset_wire.edges()
return offset_edges[0] if len(offset_edges) == 1 else offset_wire
def param_at(self, position: float) -> float:
"""
Map a normalized arc-length position to the underlying OCCT parameter.
The meaning of the returned parameter depends on the type of self:
- **Edge**: Returns the native OCCT curve parameter corresponding to the
given normalized `position` (0.0 → start, 1.0 → end). For closed/periodic
edges, OCCT may return a value **outside** the edge's nominal parameter
range `[param_min, param_max]` (e.g., by adding/subtracting multiples of
the period). If you require a value folded into the edge's range, apply a
modulo with the parameter span.
- **Wire**: Returns a *composite* parameter encoding both the edge index
and the position within that edge: the **integer part** is the zero-based
count of fully traversed edges, and the **fractional part** is the
normalized position in `[0.0, 1.0]` along the current edge.
Args:
position (float): Normalized arc-length position along the shape,
where `0.0` is the start and `1.0` is the end. Values outside
`[0.0, 1.0]` are not validated and yield OCCT-dependent results.
Returns:
float: OCCT parameter (for edges) **or** composite “edgeIndex + fraction”
parameter (for wires), as described above.
"""
curve = self.geom_adaptor()
length = GCPnts_AbscissaPoint.Length_s(curve)
return GCPnts_AbscissaPoint(
curve, length * position, curve.FirstParameter()
).Parameter()
def perpendicular_line(
self, length: float, u_value: float, plane: Plane = Plane.XY
) -> Edge:
"""perpendicular_line
Create a line on the given plane perpendicular to and centered on beginning of self
Args:
length (float): line length
u_value (float): position along line between 0.0 and 1.0
plane (Plane, optional): plane containing perpendicular line. Defaults to Plane.XY.
Returns:
Edge: perpendicular line
"""
start = self.position_at(u_value)
local_plane = Plane(
origin=start, x_dir=self.tangent_at(u_value), z_dir=plane.z_dir
)
line = Edge.make_line(
start + local_plane.y_dir * length / 2,
start - local_plane.y_dir * length / 2,
)
return line
def position_at(
self, position: float, position_mode: PositionMode = PositionMode.PARAMETER
) -> Vector:
"""Position At
Generate a position along the underlying Wire.
Args:
position (float): distance or parameter value
position_mode (PositionMode, optional): position calculation mode. Defaults to
PositionMode.PARAMETER.
Returns:
Vector: position on the underlying curve
"""
# Find the TopoDS_Edge and parameter on that edge at given position
edge_curve_adaptor, occt_edge_param, _ = self._occt_param_at(
position, position_mode
)
return Vector(edge_curve_adaptor.Value(occt_edge_param))
def positions(
self,
distances: Iterable[float],
position_mode: PositionMode = PositionMode.PARAMETER,
) -> list[Vector]:
"""Positions along curve
Generate positions along the underlying curve
Args:
distances (Iterable[float]): distance or parameter values
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
Returns:
list[Vector]: positions along curve
"""
return [self.position_at(d, position_mode) for d in distances]
def project(
self, face: Face, direction: VectorLike, closest: bool = True
) -> Edge | Wire | ShapeList[Edge | Wire]:
"""Project onto a face along the specified direction
Args:
face: Face:
direction: VectorLike:
closest: bool: (Default value = True)
Returns:
"""
if self._wrapped is None or not face:
raise ValueError("Can't project an empty Edge or Wire onto empty Face")
bldr = BRepProj_Projection(
self.wrapped, face.wrapped, Vector(direction).to_dir()
)
shapes: TopoDS_Compound = bldr.Shape()
# select the closest projection if requested
return_value: Edge | Wire | ShapeList[Edge | Wire]
if closest:
dist_calc = BRepExtrema_DistShapeShape()
dist_calc.LoadS1(self.wrapped)
min_dist = inf
# for shape in shapes:
for shape in get_top_level_topods_shapes(shapes):
dist_calc.LoadS2(shape)
dist_calc.Perform()
dist = dist_calc.Value()
if dist < min_dist:
min_dist = dist
return_value = Mixin1D.cast(shape)
else:
return_value = ShapeList(
Mixin1D.cast(shape) for shape in get_top_level_topods_shapes(shapes)
)
return return_value
def project_to_viewport(
self,
viewport_origin: VectorLike,
viewport_up: VectorLike = (0, 0, 1),
look_at: VectorLike | None = None,
focus: float | None = None,
) -> tuple[ShapeList[Edge], ShapeList[Edge]]:
"""project_to_viewport
Project a shape onto a viewport returning visible and hidden Edges.
Args:
viewport_origin (VectorLike): location of viewport
viewport_up (VectorLike, optional): direction of the viewport y axis.
Defaults to (0, 0, 1).
look_at (VectorLike, optional): point to look at.
Defaults to None (center of shape).
focus (float, optional): the focal length for perspective projection
Defaults to None (orthographic projection)
Returns:
tuple[ShapeList[Edge],ShapeList[Edge]]: visible & hidden Edges
"""
def extract_edges(compound):
edges = [] # List to store the extracted edges
# Create a TopExp_Explorer to traverse the sub-shapes of the compound
explorer = TopExp_Explorer(compound, TopAbs_ShapeEnum.TopAbs_EDGE)
# Loop through the sub-shapes and extract edges
while explorer.More():
edge = downcast(explorer.Current())
edges.append(edge)
explorer.Next()
return edges
if self._wrapped is None:
raise ValueError("Can't project empty edge/wire")
# Setup the projector
hidden_line_removal = HLRBRep_Algo()
hidden_line_removal.Add(self.wrapped)
viewport_origin = Vector(viewport_origin)
look_at = Vector(look_at) if look_at else self.center()
projection_dir: Vector = (viewport_origin - look_at).normalized()
viewport_up = Vector(viewport_up).normalized()
camera_coordinate_system = gp_Ax2()
camera_coordinate_system.SetAxis(
gp_Ax1(viewport_origin.to_pnt(), projection_dir.to_dir())
)
camera_coordinate_system.SetYDirection(viewport_up.to_dir())
projector = (
HLRAlgo_Projector(camera_coordinate_system, focus)
if focus
else HLRAlgo_Projector(camera_coordinate_system)
)
hidden_line_removal.Projector(projector)
hidden_line_removal.Update()
hidden_line_removal.Hide()
hlr_shapes = HLRBRep_HLRToShape(hidden_line_removal)
# Create the visible edges
visible_edges = []
for edges in [
hlr_shapes.VCompound(),
hlr_shapes.Rg1LineVCompound(),
hlr_shapes.OutLineVCompound(),
]:
if not edges.IsNull():
visible_edges.extend(extract_edges(downcast(edges)))
# Create the hidden edges
hidden_edges = []
for edges in [
hlr_shapes.HCompound(),
hlr_shapes.OutLineHCompound(),
hlr_shapes.Rg1LineHCompound(),
]:
if not edges.IsNull():
hidden_edges.extend(extract_edges(downcast(edges)))
# Fix the underlying geometry - otherwise we will get segfaults
for edge in visible_edges:
BRepLib.BuildCurves3d_s(edge, TOLERANCE)
for edge in hidden_edges:
BRepLib.BuildCurves3d_s(edge, TOLERANCE)
# convert to native shape objects
visible_edges = ShapeList(Edge(e) for e in visible_edges)
hidden_edges = ShapeList(Edge(e) for e in hidden_edges)
return (visible_edges, hidden_edges)
@overload
def split(
self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM]
) -> Self | list[Self] | None:
"""split and keep inside or outside"""
@overload
def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]:
"""split and return the unordered pieces"""
@overload
def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[
Self | list[Self] | None,
Self | list[Self] | None,
]:
"""split and keep inside and outside"""
@overload
def split(self, tool: TrimmingTool) -> Self | list[Self] | None:
"""split and keep inside (default)"""
def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP):
"""split
Split this shape by the provided plane or face.
Args:
surface (Plane | Face): surface to segment shape
keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP.
Returns:
Shape: result of split
Returns:
Self | list[Self] | None,
Tuple[Self | list[Self] | None]: The result of the split operation.
- **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None`
if no top is found.
- **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None`
if no bottom is found.
- **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is
either a `Self` or `list[Self]`, or `None` if no corresponding part is found.
"""
if self._wrapped is None or not tool:
raise ValueError("Can't split an empty edge/wire/tool")
shape_list = TopTools_ListOfShape()
shape_list.Append(self.wrapped)
# Define the splitting tool
trim_tool = (
BRepBuilderAPI_MakeFace(tool.wrapped).Face() # gp_Pln to Face
if isinstance(tool, Plane)
else tool.wrapped
)
tool_list = TopTools_ListOfShape()
tool_list.Append(trim_tool)
# Create the splitter algorithm
splitter = BRepAlgoAPI_Splitter()
# Set the shape to be split and the splitting tool (plane face)
splitter.SetArguments(shape_list)
splitter.SetTools(tool_list)
# Perform the splitting operation
splitter.Build()
split_result = downcast(splitter.Shape())
# Remove unnecessary TopoDS_Compound around single shape
if isinstance(split_result, TopoDS_Compound):
split_result = unwrap_topods_compound(split_result, True)
# For speed the user may just want all the objects which they
# can sort more efficiently then the generic algorithm below
if keep == Keep.ALL:
return ShapeList(
self.__class__.cast(part)
for part in get_top_level_topods_shapes(split_result)
)
if not isinstance(tool, Plane):
# Get a TopoDS_Face to work with from the tool
if isinstance(trim_tool, TopoDS_Shell):
face_explorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE)
tool_face = TopoDS.Face_s(face_explorer.Current())
else:
tool_face = trim_tool
# Create a reference point off the +ve side of the tool
surface_gppnt = gp_Pnt()
surface_normal = gp_Vec()
u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face)
BRepGProp_Face(tool_face).Normal(
(u_min + u_max) / 2, (v_min + v_max) / 2, surface_gppnt, surface_normal
)
normalized_surface_normal = Vector(
surface_normal.X(), surface_normal.Y(), surface_normal.Z()
).normalized()
surface_point = Vector(surface_gppnt)
ref_point = surface_point + normalized_surface_normal
# Create a HalfSpace - Solidish object to determine top/bottom
# Note: BRepPrimAPI_MakeHalfSpace takes either a TopoDS_Shell or TopoDS_Face but the
# mypy expects only a TopoDS_Shell here
half_space_maker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt())
# type: ignore
tool_solid = half_space_maker.Solid()
tops: list[Shape] = []
bottoms: list[Shape] = []
properties = GProp_GProps()
for part in get_top_level_topods_shapes(split_result):
sub_shape = self.__class__.cast(part)
if isinstance(tool, Plane):
is_up = tool.to_local_coords(sub_shape).center().Z >= 0
else:
# Intersect self and the thickened tool
is_up_obj = _topods_bool_op(
(part,), (tool_solid,), BRepAlgoAPI_Common()
)
# Check for valid intersections
BRepGProp.LinearProperties_s(is_up_obj, properties)
# Mass represents the total length for linear properties
is_up = properties.Mass() >= TOLERANCE
(tops if is_up else bottoms).append(sub_shape)
top = None if not tops else tops[0] if len(tops) == 1 else tops
bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms
if keep == Keep.BOTH:
return (top, bottom)
if keep == Keep.TOP:
return top
if keep == Keep.BOTTOM:
return bottom
return None
def start_point(self) -> Vector:
"""The start point of this edge
Note that circles may have identical start and end points.
"""
curve = self.geom_adaptor()
umin = curve.FirstParameter() if self.is_forward else curve.LastParameter()
return Vector(curve.Value(umin))
def tangent_angle_at(
self,
location_param: float = 0.5,
position_mode: PositionMode = PositionMode.PARAMETER,
plane: Plane = Plane.XY,
) -> float:
"""tangent_angle_at
Compute the tangent angle at the specified location
Args:
location_param (float, optional): distance or parameter value. Defaults to 0.5.
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
plane (Plane, optional): plane line was constructed on. Defaults to Plane.XY.
Returns:
float: angle in degrees between 0 and 360
"""
tan_vector = self.tangent_at(location_param, position_mode)
angle = (plane.x_dir.get_signed_angle(tan_vector, plane.z_dir) + 360) % 360.0
return angle
def tangent_at(
self,
position: float | VectorLike = 0.5,
position_mode: PositionMode = PositionMode.PARAMETER,
) -> Vector:
"""tangent_at
Find the tangent at a given position on the 1D shape where the position
is either a float (or int) parameter or a point that lies on the shape.
Args:
position (float | VectorLike): distance, parameter value, or
point on shape. Defaults to 0.5.
position_mode (PositionMode, optional): position calculation mode.
Defaults to PositionMode.PARAMETER.
Returns:
Vector: tangent value
"""
return self.derivative_at(position, 1, position_mode).normalized()
def vertex(self) -> Vertex | None:
"""Return the Vertex"""
return Shape.get_single_shape(self, "Vertex")
def vertices(self) -> ShapeList[Vertex]:
"""vertices - all the vertices in this Shape"""
return Shape.get_shape_list(self, "Vertex")
def wire(self) -> Wire | None:
"""Return the Wire"""
return Shape.get_single_shape(self, "Wire")
def wires(self) -> ShapeList[Wire]:
"""wires - all the wires in this Shape"""
return Shape.get_shape_list(self, "Wire")
class Edge(Mixin1D[TopoDS_Edge]):
"""An Edge in build123d is a fundamental element in the topological data structure
representing a one-dimensional geometric entity within a 3D model. It encapsulates
information about a curve, which could be a line, arc, or other parametrically
defined shape. Edge is crucial in for precise modeling and manipulation of curves,
facilitating operations like filleting, chamfering, and Boolean operations. It
serves as a building block for constructing complex structures, such as wires
and faces."""
# pylint: disable=too-many-public-methods
order = 1.0
# ---- Constructor ----
def __init__(
self,
obj: TopoDS_Edge | Axis | None | None = None,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build an Edge from an OCCT TopoDS_Shape/TopoDS_Edge
Args:
obj (TopoDS_Edge | Axis, optional): OCCT Edge or Axis.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
if isinstance(obj, Axis):
obj = BRepBuilderAPI_MakeEdge(
Geom_Line(
obj.position.to_pnt(),
obj.direction.to_dir(),
)
).Edge()
super().__init__(
obj=obj,
label=label,
color=color,
parent=parent,
)
# ---- Properties ----
@property
def arc_center(self) -> Vector:
"""center of an underlying circle or ellipse geometry."""
geom_type = self.geom_type
geom_adaptor = self.geom_adaptor()
if geom_type == GeomType.CIRCLE:
return_value = Vector(geom_adaptor.Circle().Position().Location())
elif geom_type == GeomType.ELLIPSE:
return_value = Vector(geom_adaptor.Ellipse().Position().Location())
else:
raise ValueError(f"{geom_type} has no arc center")
return return_value
# ---- Class Methods ----
@classmethod
def extrude(cls, obj: Vertex, direction: VectorLike) -> Edge:
"""extrude
Extrude a Vertex into an Edge.
Args:
direction (VectorLike): direction and magnitude of extrusion
Raises:
ValueError: Unsupported class
RuntimeError: Generated invalid result
Returns:
Edge: extruded shape
"""
if not obj:
raise ValueError("Can't extrude empty vertex")
return Edge(TopoDS.Edge_s(_extrude_topods_shape(obj.wrapped, direction)))
@classmethod
def make_bezier(
cls, *cntl_pnts: VectorLike, weights: list[float] | None = None
) -> Edge:
"""make_bezier
Create a rational (with weights) or non-rational bezier curve. The first and last
control points represent the start and end of the curve respectively. If weights
are provided, there must be one provided for each control point.
Args:
cntl_pnts (sequence[VectorLike]): points defining the curve
weights (list[float], optional): control point weights list. Defaults to None.
Raises:
ValueError: Too few control points
ValueError: Too many control points
ValueError: A weight is required for each control point
Returns:
Edge: bezier curve
"""
if len(cntl_pnts) < 2:
raise ValueError(
"At least two control points must be provided (start, end)"
)
if len(cntl_pnts) > 25:
raise ValueError("The maximum number of control points is 25")
if weights:
if len(cntl_pnts) != len(weights):
raise ValueError("A weight must be provided for each control point")
cntl_gp_pnts = [Vector(cntl_pnt).to_pnt() for cntl_pnt in cntl_pnts]
# The poles are stored in an OCCT Array object
poles = TColgp_Array1OfPnt(1, len(cntl_gp_pnts))
for i, cntl_gp_pnt in enumerate(cntl_gp_pnts):
poles.SetValue(i + 1, cntl_gp_pnt)
if weights:
pole_weights = TColStd_Array1OfReal(1, len(weights))
for i, weight in enumerate(weights):
pole_weights.SetValue(i + 1, float(weight))
bezier_curve = Geom_BezierCurve(poles, pole_weights)
else:
bezier_curve = Geom_BezierCurve(poles)
return cls(BRepBuilderAPI_MakeEdge(bezier_curve).Edge())
@classmethod
def make_circle(
cls,
radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
) -> Edge:
"""make circle
Create a circle centered on the origin of plane
Args:
radius (float): circle radius
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): start of arc angle. Defaults to 360.0.
end_angle (float, optional): end of arc angle. Defaults to 360.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
Returns:
Edge: full or partial circle
"""
circle_gp = gp_Circ(plane.to_gp_ax2(), radius)
if start_angle == end_angle: # full circle case
return_value = cls(BRepBuilderAPI_MakeEdge(circle_gp).Edge())
else: # arc case
ccw = angular_direction == AngularDirection.COUNTER_CLOCKWISE
if ccw:
start = radians(start_angle)
end = radians(end_angle)
else:
start = radians(end_angle)
end = radians(start_angle)
circle_geom = GC_MakeArcOfCircle(circle_gp, start, end, ccw).Value()
return_value = cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
return return_value
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
radius: float,
sagitta: Sagitta = Sagitta.SHORT,
) -> ShapeList[Edge]:
"""
Create all planar circular arcs of a given radius that are tangent/contacting
the two provided objects on the XY plane.
Args:
tangency_one, tangency_two
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entities to be contacted/touched by the circle(s)
radius (float): arc radius
sagitta (LengthConstraint, optional): returned arc selector
(i.e. either the short, long or both arcs). Defaults to
LengthConstraint.SHORT.
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
center_on: Axis | Edge,
sagitta: Sagitta = Sagitta.SHORT,
) -> ShapeList[Edge]:
"""
Create all planar circular arcs whose circle is tangent to two objects and whose
CENTER lies on a given locus (line/circle/curve) on the XY plane.
Args:
tangency_one, tangency_two
(tuple[Axus | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entities to be contacted/touched by the circle(s)
center_on (Axis | Edge): center must lie on this object
sagitta (LengthConstraint, optional): returned arc selector
(i.e. either the short, long or both arcs). Defaults to
LengthConstraint.SHORT.
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_two: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
tangency_three: (
tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike
),
*,
sagitta: Sagitta = Sagitta.SHORT,
) -> ShapeList[Edge]:
"""
Create planar circular arc(s) on XY tangent to three provided objects.
Args:
tangency_one, tangency_two, tangency_three
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entities to be contacted/touched by the circle(s)
sagitta (LengthConstraint, optional): returned arc selector
(i.e. either the short, long or both arcs). Defaults to
LengthConstraint.SHORT.
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
center: VectorLike,
) -> ShapeList[Edge]:
"""make_constrained_arcs
Create planar circle(s) on XY whose center is fixed and that are tangent/contacting
a single object.
Args:
tangency_one
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entity to be contacted/touched by the circle(s)
center (VectorLike): center position
Returns:
ShapeList[Edge]: tangent arcs
"""
@overload
@classmethod
def make_constrained_arcs(
cls,
tangency_one: tuple[Axis | Edge, Tangency] | Axis | Edge | Vertex | VectorLike,
*,
radius: float,
center_on: Edge,
) -> ShapeList[Edge]:
"""make_constrained_arcs
Create planar circle(s) on XY that:
- are tangent/contacting a single object, and
- have a fixed radius, and
- have their CENTER constrained to lie on a given locus curve.
Args:
tangency_one
(tuple[Axis | Edge, PositionConstraint] | Axis | Edge | Vertex | VectorLike):
Geometric entity to be contacted/touched by the circle(s)
radius (float): arc radius
center_on (Axis | Edge): center must lie on this object
sagitta (LengthConstraint, optional): returned arc selector
(i.e. either the short, long or both arcs). Defaults to
LengthConstraint.SHORT.
Returns:
ShapeList[Edge]: tangent arcs
"""
@classmethod
def make_constrained_arcs(
cls,
*args,
sagitta: Sagitta = Sagitta.SHORT,
**kwargs,
) -> ShapeList[Edge]:
tangency_one = args[0] if len(args) > 0 else None
tangency_two = args[1] if len(args) > 1 else None
tangency_three = args[2] if len(args) > 2 else None
tangency_one = kwargs.pop("tangency_one", tangency_one)
tangency_two = kwargs.pop("tangency_two", tangency_two)
tangency_three = kwargs.pop("tangency_three", tangency_three)
radius = kwargs.pop("radius", None)
center = kwargs.pop("center", None)
center_on = kwargs.pop("center_on", None)
# Handle unexpected kwargs
if kwargs:
raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
tangency_args = [
t for t in (tangency_one, tangency_two, tangency_three) if t is not None
]
tangencies: list[tuple[Edge, Tangency] | Edge | Vector] = []
for tangency_arg in tangency_args:
if isinstance(tangency_arg, Axis):
tangencies.append(Edge(tangency_arg))
continue
elif isinstance(tangency_arg, Edge):
tangencies.append(tangency_arg)
continue
if isinstance(tangency_arg, tuple):
if isinstance(tangency_arg[0], Axis):
tangencies.append(tuple(Edge(tangency_arg[0], tangency_arg[1])))
continue
elif isinstance(tangency_arg[0], Edge):
tangencies.append(tangency_arg)
continue
if isinstance(tangency_arg, Vertex):
tangencies.append(Vector(tangency_arg) + tangency_arg.position)
continue
# if not Axes, Edges, constrained Edges or Vertex convert to Vectors
try:
tangencies.append(Vector(tangency_arg))
except Exception as exc:
raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc
# # Sort the tangency inputs so points are always last
tangencies = sorted(tangencies, key=lambda x: isinstance(x, Vector))
tan_count = len(tangencies)
if not (1 <= tan_count <= 3):
raise TypeError("Provide 1 to 3 tangency targets.")
# Radius sanity
if radius is not None and radius <= 0:
raise ValueError("radius must be > 0.0")
if center_on is not None and isinstance(center_on, Axis):
center_on = Edge(center_on)
# --- decide problem kind ---
if (
tan_count == 2
and radius is not None
and center is None
and center_on is None
):
return _make_2tan_rad_arcs(
*tangencies,
radius=radius,
sagitta=sagitta,
edge_factory=cls,
)
if (
tan_count == 2
and center_on is not None
and radius is None
and center is None
):
return _make_2tan_on_arcs(
*tangencies,
center_on=center_on,
sagitta=sagitta,
edge_factory=cls,
)
if tan_count == 3 and radius is None and center is None and center_on is None:
return _make_3tan_arcs(*tangencies, sagitta=sagitta, edge_factory=cls)
if (
tan_count == 1
and center is not None
and radius is None
and center_on is None
):
return _make_tan_cen_arcs(*tangencies, center=center, edge_factory=cls)
if tan_count == 1 and center_on is not None and radius is not None:
return _make_tan_on_rad_arcs(
*tangencies, center_on=center_on, radius=radius, edge_factory=cls
)
raise ValueError("Unsupported or ambiguous combination of constraints.")
@overload
@classmethod
def make_constrained_lines(
cls,
tangency_one: tuple[Edge, Tangency] | Axis | Edge,
tangency_two: tuple[Edge, Tangency] | Axis | Edge,
) -> ShapeList[Edge]:
"""
Create all planar line(s) on the XY plane tangent to two provided curves.
Args:
tangency_one, tangency_two
(tuple[Edge, Tangency] | Axis | Edge):
Geometric entities to be contacted/touched by the line(s).
Returns:
ShapeList[Edge]: tangent lines
"""
@overload
@classmethod
def make_constrained_lines(
cls,
tangency_one: tuple[Edge, Tangency] | Edge,
tangency_two: Vector,
) -> ShapeList[Edge]:
"""
Create all planar line(s) on the XY plane tangent to one curve and passing
through a fixed point.
Args:
tangency_one
(tuple[Edge, Tangency] | Edge):
Geometric entity to be contacted/touched by the line(s).
tangency_two (Vector):
Fixed point through which the line(s) must pass.
Returns:
ShapeList[Edge]: tangent lines
"""
@overload
@classmethod
def make_constrained_lines(
cls,
tangency_one: tuple[Edge, Tangency] | Edge,
tangency_two: Axis,
*,
angle: float | None = None,
direction: VectorLike | None = None,
) -> ShapeList[Edge]:
"""
Create all planar line(s) on the XY plane tangent to one curve and passing
through a fixed point.
Args:
tangency_one (Edge): edge that line will be tangent to
tangency_two (Axis): axis that angle will be measured against
angle : float, optional
Line orientation in degrees (measured CCW from the X-axis).
direction : VectorLike, optional
Direction vector for the line (only X and Y components are used).
Note: one of angle or direction must be provided
Returns:
ShapeList[Edge]: tangent lines
"""
@classmethod
def make_constrained_lines(cls, *args, **kwargs) -> ShapeList[Edge]:
"""
Create planar line(s) on XY subject to tangency/contact constraints.
Supported cases
---------------
1. Tangent to two curves
2. Tangent to one curve and passing through a given point
"""
tangency_one = args[0] if len(args) > 0 else None
tangency_two = args[1] if len(args) > 1 else None
tangency_one = kwargs.pop("tangency_one", tangency_one)
tangency_two = kwargs.pop("tangency_two", tangency_two)
angle = kwargs.pop("angle", None)
direction = kwargs.pop("direction", None)
direction = Vector(direction) if direction is not None else None
is_ref = angle is not None or direction is not None
# Handle unexpected kwargs
if kwargs:
raise TypeError(f"Unexpected argument(s): {', '.join(kwargs.keys())}")
tangency_args = [t for t in (tangency_one, tangency_two) if t is not None]
if len(tangency_args) != 2:
raise TypeError("Provide exactly 2 tangency targets.")
tangencies: list[tuple[Edge, Tangency] | Axis | Edge | Vector] = []
for i, tangency_arg in enumerate(tangency_args):
if isinstance(tangency_arg, Axis):
if i == 1 and is_ref:
tangencies.append(tangency_arg)
else:
tangencies.append(Edge(tangency_arg))
continue
elif isinstance(tangency_arg, Edge):
tangencies.append(tangency_arg)
continue
if isinstance(tangency_arg, tuple) and isinstance(tangency_arg[0], Edge):
tangencies.append(tangency_arg)
continue
# Fallback: treat as a point
try:
tangencies.append(Vector(tangency_arg))
except Exception as exc:
raise TypeError(f"Invalid tangency: {tangency_arg!r}") from exc
# Sort so Vector (point) | Axis is always last
tangencies = sorted(tangencies, key=lambda x: isinstance(x, (Axis, Vector)))
# --- decide problem kind ---
if angle is not None or direction is not None:
if isinstance(tangencies[0], tuple):
assert isinstance(
tangencies[0][0], Edge
), "Internal error - 1st tangency must be Edge"
else:
assert isinstance(
tangencies[0], Edge
), "Internal error - 1st tangency must be Edge"
if angle is not None:
ang_rad = radians(angle)
else:
assert direction is not None
ang_rad = atan2(direction.Y, direction.X)
assert isinstance(
tangencies[1], Axis
), "Internal error - 2nd tangency must be an Axis"
return _make_tan_oriented_lines(
tangencies[0], tangencies[1], ang_rad, edge_factory=cls
)
else:
assert not isinstance(
tangencies[0], (Axis, Vector)
), "Internal error - 1st tangency can't be an Axis | Vector"
assert not isinstance(
tangencies[1], Axis
), "Internal error - 2nd tangency can't be an Axis"
return _make_2tan_lines(tangencies[0], tangencies[1], edge_factory=cls)
@classmethod
def make_ellipse(
cls,
x_radius: float,
y_radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360.0,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
) -> Edge:
"""make ellipse
Makes an ellipse centered at the origin of plane.
Args:
x_radius (float): x radius of the ellipse (along the x-axis of plane)
y_radius (float): y radius of the ellipse (along the y-axis of plane)
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): Defaults to 360.0.
end_angle (float, optional): Defaults to 360.0.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
Returns:
Edge: full or partial ellipse
"""
ax1 = gp_Ax1(plane.origin.to_pnt(), plane.z_dir.to_dir())
if y_radius > x_radius:
# swap x and y radius and rotate by 90° afterwards to create an ellipse
# with x_radius < y_radius
correction_angle = 90.0 * DEG2RAD
ellipse_gp = gp_Elips(plane.to_gp_ax2(), y_radius, x_radius).Rotated(
ax1, correction_angle
)
else:
correction_angle = 0.0
ellipse_gp = gp_Elips(plane.to_gp_ax2(), x_radius, y_radius)
if start_angle == end_angle: # full ellipse case
ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_gp).Edge())
else: # arc case
# take correction_angle into account
ellipse_geom = GC_MakeArcOfEllipse(
ellipse_gp,
start_angle * DEG2RAD - correction_angle,
end_angle * DEG2RAD - correction_angle,
angular_direction == AngularDirection.COUNTER_CLOCKWISE,
).Value()
ellipse = cls(BRepBuilderAPI_MakeEdge(ellipse_geom).Edge())
return ellipse
@classmethod
def make_helix(
cls,
pitch: float,
height: float,
radius: float,
center: VectorLike = (0, 0, 0),
normal: VectorLike = (0, 0, 1),
angle: float = 0.0,
lefthand: bool = False,
) -> Wire:
"""make_helix
Make a helix with a given pitch, height and radius. By default a cylindrical surface is
used to create the helix. If the :angle: is set (the apex given in degree) a conical
surface is used instead.
Args:
pitch (float): distance per revolution along normal
height (float): total height
radius (float):
center (VectorLike, optional): Defaults to (0, 0, 0).
normal (VectorLike, optional): Defaults to (0, 0, 1).
angle (float, optional): conical angle. Defaults to 0.0.
lefthand (bool, optional): Defaults to False.
Returns:
Wire: helix
"""
# pylint: disable=too-many-locals
# 1. build underlying cylindrical/conical surface
if angle == 0.0:
geom_surf: Geom_Surface = Geom_CylindricalSurface(
gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()), radius
)
else:
geom_surf = Geom_ConicalSurface(
gp_Ax3(Vector(center).to_pnt(), Vector(normal).to_dir()),
angle * DEG2RAD,
radius,
)
# 2. construct an segment in the u,v domain
# Determine the length of the 2d line which will be wrapped around the surface
line_sign = -1 if lefthand else 1
line_dir = Vector(line_sign * 2 * pi, pitch).normalized()
line_len = (height / line_dir.Y) / cos(radians(angle))
# Create an infinite 2d line in the direction of the helix
helix_line = Geom2d_Line(gp_Pnt2d(0, 0), gp_Dir2d(line_dir.X, line_dir.Y))
# Trim the line to the desired length
helix_curve = Geom2d_TrimmedCurve(
helix_line, 0, line_len, theAdjustPeriodic=True
)
# 3. Wrap the line around the surface
edge_builder = BRepBuilderAPI_MakeEdge(helix_curve, geom_surf)
topods_edge = edge_builder.Edge()
# 4. Convert the edge made with 2d geometry to 3d
BRepLib.BuildCurves3d_s(topods_edge, 1e-9, MaxSegment=2000)
return cls(topods_edge)
@classmethod
def make_line(cls, point1: VectorLike, point2: VectorLike) -> Edge:
"""Create a line between two points
Args:
point1: VectorLike: that represents the first point
point2: VectorLike: that represents the second point
Returns:
A linear edge between the two provided points
"""
return cls(
BRepBuilderAPI_MakeEdge(
Vector(point1).to_pnt(), Vector(point2).to_pnt()
).Edge()
)
@classmethod
def make_mid_way(cls, first: Edge, second: Edge, middle: float = 0.5) -> Edge:
"""make line between edges
Create a new linear Edge between the two provided Edges. If the Edges are parallel
but in the opposite directions one Edge is flipped such that the mid way Edge isn't
truncated.
Args:
first (Edge): first reference Edge
second (Edge): second reference Edge
middle (float, optional): factional distance between Edges. Defaults to 0.5.
Returns:
Edge: linear Edge between two Edges
"""
flip = Axis(first).is_opposite(Axis(second))
pnts = [
Edge.make_line(
first.position_at(i), second.position_at(1 - i if flip else i)
).position_at(middle)
for i in [0, 1]
]
return Edge.make_line(*pnts)
@classmethod
def make_spline(
cls,
points: list[VectorLike],
tangents: list[VectorLike] | None = None,
periodic: bool = False,
parameters: list[float] | None = None,
scale: bool = True,
tol: float = 1e-6,
) -> Edge:
"""Spline
Interpolate a spline through the provided points.
Args:
points (list[VectorLike]): the points defining the spline
tangents (list[VectorLike], optional): start and finish tangent.
Defaults to None.
periodic (bool, optional): creation of periodic curves. Defaults to False.
parameters (list[float], optional): the value of the parameter at each
interpolation point. (The interpolated curve is represented as a vector-valued
function of a scalar parameter.) If periodic == True, then len(parameters)
must be len(interpolation points) + 1, otherwise len(parameters)
must be equal to len(interpolation points). Defaults to None.
scale (bool, optional): whether to scale the specified tangent vectors before
interpolating. Each tangent is scaled, so it's length is equal to the derivative
of the Lagrange interpolated curve. I.e., set this to True, if you want to use
only the direction of the tangent vectors specified by `tangents` , but not
their magnitude. Defaults to True.
tol (float, optional): tolerance of the algorithm (consult OCC documentation).
Used to check that the specified points are not too close to each other, and
that tangent vectors are not too short. (In either case interpolation may fail.).
Defaults to 1e-6.
Raises:
ValueError: Parameter for each interpolation point
ValueError: Tangent for each interpolation point
ValueError: B-spline interpolation failed
Returns:
Edge: the spline
"""
# pylint: disable=too-many-locals
point_vectors = [Vector(point) for point in points]
if tangents:
tangent_vectors = tuple(Vector(v) for v in tangents)
pnts = TColgp_HArray1OfPnt(1, len(point_vectors))
for i, point in enumerate(point_vectors):
pnts.SetValue(i + 1, point.to_pnt())
if parameters is None:
spline_builder = GeomAPI_Interpolate(pnts, periodic, tol)
else:
if len(parameters) != (len(point_vectors) + periodic):
raise ValueError(
"There must be one parameter for each interpolation point "
"(plus one if periodic), or none specified. Parameter count: "
f"{len(parameters)}, point count: {len(point_vectors)}"
)
parameters_array = TColStd_HArray1OfReal(1, len(parameters))
for p_index, p_value in enumerate(parameters):
parameters_array.SetValue(p_index + 1, p_value)
spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol)
if tangents:
if len(tangent_vectors) == 2 and len(point_vectors) != 2:
# Specify only initial and final tangent:
spline_builder.Load(
tangent_vectors[0].wrapped, tangent_vectors[1].wrapped, scale
)
else:
if len(tangent_vectors) != len(point_vectors):
raise ValueError(
f"There must be one tangent for each interpolation point, "
f"or just two end point tangents. Tangent count: "
f"{len(tangent_vectors)}, point count: {len(point_vectors)}"
)
# Specify a tangent for each interpolation point:
tangents_array = TColgp_Array1OfVec(1, len(tangent_vectors))
tangent_enabled_array = TColStd_HArray1OfBoolean(
1, len(tangent_vectors)
)
for t_index, t_value in enumerate(tangent_vectors):
tangent_enabled_array.SetValue(t_index + 1, t_value is not None)
tangent_vec = t_value if t_value is not None else Vector()
tangents_array.SetValue(t_index + 1, tangent_vec.wrapped)
spline_builder.Load(tangents_array, tangent_enabled_array, scale)
spline_builder.Perform()
if not spline_builder.IsDone():
raise ValueError("B-spline interpolation failed")
spline_geom = spline_builder.Curve()
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_spline_approx(
cls,
points: list[VectorLike],
tol: float = 1e-3,
smoothing: tuple[float, float, float] | None = None,
min_deg: int = 1,
max_deg: int = 6,
) -> Edge:
"""make_spline_approx
Approximate a spline through the provided points.
Args:
points (list[Vector]):
tol (float, optional): tolerance of the algorithm. Defaults to 1e-3.
smoothing (Tuple[float, float, float], optional): optional tuple of 3 weights
use for variational smoothing. Defaults to None.
min_deg (int, optional): minimum spline degree. Enforced only when smoothing
is None. Defaults to 1.
max_deg (int, optional): maximum spline degree. Defaults to 6.
Raises:
ValueError: B-spline approximation failed
Returns:
Edge: spline
"""
pnts = TColgp_HArray1OfPnt(1, len(points))
for i, point in enumerate(points):
pnts.SetValue(i + 1, Vector(point).to_pnt())
if smoothing:
spline_builder = GeomAPI_PointsToBSpline(
pnts, *smoothing, DegMax=max_deg, Tol3D=tol
)
else:
spline_builder = GeomAPI_PointsToBSpline(
pnts, DegMin=min_deg, DegMax=max_deg, Tol3D=tol
)
if not spline_builder.IsDone():
raise ValueError("B-spline approximation failed")
spline_geom = spline_builder.Curve()
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_tangent_arc(
cls, start: VectorLike, tangent: VectorLike, end: VectorLike
) -> Edge:
"""Tangent Arc
Makes a tangent arc from point start, in the direction of tangent and ends at end.
Args:
start (VectorLike): start point
tangent (VectorLike): start tangent
end (VectorLike): end point
Returns:
Edge: circular arc
"""
circle_geom = GC_MakeArcOfCircle(
Vector(start).to_pnt(), Vector(tangent).wrapped, Vector(end).to_pnt()
).Value()
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
@classmethod
def make_three_point_arc(
cls, point1: VectorLike, point2: VectorLike, point3: VectorLike
) -> Edge:
"""Three Point Arc
Makes a three point arc through the provided points
Args:
point1 (VectorLike): start point
point2 (VectorLike): middle point
point3 (VectorLike): end point
Returns:
Edge: a circular arc through the three points
"""
circle_geom = GC_MakeArcOfCircle(
Vector(point1).to_pnt(), Vector(point2).to_pnt(), Vector(point3).to_pnt()
).Value()
return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge())
# ---- Instance Methods ----
def close(self) -> Edge | Wire:
"""Close an Edge"""
if not self.is_closed:
return_value = Wire([self]).close()
else:
return_value = self
return return_value
def distribute_locations(
self: Wire | Edge,
count: int,
start: float = 0.0,
stop: float = 1.0,
positions_only: bool = False,
) -> list[Location]:
"""Distribute Locations
Distribute locations along edge or wire.
Args:
self: Wire:Edge:
count(int): Number of locations to generate
start(float): position along Edge|Wire to start. Defaults to 0.0.
stop(float): position along Edge|Wire to end. Defaults to 1.0.
positions_only(bool): only generate position not orientation. Defaults to False.
Returns:
list[Location]: locations distributed along Edge|Wire
Raises:
ValueError: count must be two or greater
"""
if count < 2:
raise ValueError("count must be two or greater")
t_values = [start + i * (stop - start) / (count - 1) for i in range(count)]
locations = self.locations(t_values)
if positions_only:
for loc in locations:
loc.orientation = Vector(0, 0, 0)
return locations
def _extend_spline(
self,
at_start: bool,
geom_surface: Geom_Surface,
extension_factor: float = 0.1,
):
"""Helper method to slightly extend an edge that is bound to a surface"""
if self._wrapped is None:
raise ValueError("Can't extend empty spline")
if self.geom_type != GeomType.BSPLINE:
raise TypeError("_extend_spline only works with splines")
u_start: float = self.param_at(0)
u_end: float = self.param_at(1)
curve_original = tcast(
Geom_BSplineCurve, BRep_Tool.Curve_s(self.wrapped, u_start, u_end)
)
n_poles = curve_original.NbPoles()
poles = [curve_original.Pole(i + 1) for i in range(n_poles)]
# Find position and tangent past end of spline to extend it
ends = (-extension_factor, 1) if at_start else (0, 1 + extension_factor)
if at_start:
new_pole = self.position_at(-extension_factor).to_pnt()
poles = [new_pole] + poles
else:
new_pole = self.position_at(1 + extension_factor).to_pnt()
poles = poles + [new_pole]
tangents: list[VectorLike] = [self.tangent_at(p) for p in ends]
pnts: list[VectorLike] = [Vector(p) for p in poles]
extended_edge = Edge.make_spline(pnts, tangents=tangents)
assert extended_edge.wrapped is not None
geom_curve = BRep_Tool.Curve_s(
extended_edge.wrapped, extended_edge.param_at(0), extended_edge.param_at(1)
)
snapped_geom_curve = GeomProjLib.Project_s(geom_curve, geom_surface)
if snapped_geom_curve is None:
raise RuntimeError("Failed to snap extended edge to surface")
# Build a new projected edge
snapped_edge = Edge(BRepBuilderAPI_MakeEdge(snapped_geom_curve).Edge())
return snapped_edge, snapped_geom_curve
def find_intersection_points(
self, other: Axis | Edge | None = None, tolerance: float = TOLERANCE
) -> ShapeList[Vector]:
"""find_intersection_points
Determine the points where a 2D edge crosses itself or another 2D edge
Args:
other (Axis | Edge): curve to compare with
tolerance (float, optional): the precision of computing the intersection points.
Defaults to TOLERANCE.
Raises:
ValueError: empty edge
Returns:
ShapeList[Vector]: list of intersection points
"""
if self._wrapped is None:
raise ValueError("Can't find intersections of empty edge")
# Convert an Axis into an edge at least as large as self and Axis start point
if isinstance(other, Axis):
pos = tcast(Vector, other.position)
self_bbox_w_edge = self.bounding_box().add(Vertex(pos).bounding_box())
other = Edge.make_line(
pos + other.direction * (-1 * self_bbox_w_edge.diagonal),
pos + other.direction * self_bbox_w_edge.diagonal,
)
# To determine the 2D plane to work on
plane = self.common_plane(other)
if plane is None:
raise ValueError("All objects must be on the same plane")
# Convert the plane into a Geom_Surface
pln_shape = BRepBuilderAPI_MakeFace(plane.wrapped).Face()
edge_surface = BRep_Tool.Surface_s(pln_shape)
self_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
self.wrapped,
edge_surface,
TopLoc_Location(),
self.param_at(0),
self.param_at(1),
)
if other is not None and other.wrapped is not None:
edge_2d_curve: Geom2d_Curve = BRep_Tool.CurveOnPlane_s(
other.wrapped,
edge_surface,
TopLoc_Location(),
other.param_at(0),
other.param_at(1),
)
intersector = Geom2dAPI_InterCurveCurve(
self_2d_curve, edge_2d_curve, tolerance
)
else:
intersector = Geom2dAPI_InterCurveCurve(self_2d_curve, tolerance)
crosses = [
Vector(intersector.Point(i + 1).X(), intersector.Point(i + 1).Y())
for i in range(intersector.NbPoints())
]
# Convert back to global coordinates
crosses = [plane.from_local_coords(p) for p in crosses]
# crosses may contain points beyond the ends of the edge so
# .. filter those out
valid_crosses = []
for pnt in crosses:
try:
if other is not None:
if (
self.distance_to(pnt) <= TOLERANCE
and other.distance_to(pnt) <= TOLERANCE
):
valid_crosses.append(pnt)
else:
if self.distance_to(pnt) <= TOLERANCE:
valid_crosses.append(pnt)
except ValueError:
pass # skip invalid points
return ShapeList(valid_crosses)
def find_tangent(
self,
angle: float,
) -> list[float]:
"""find_tangent
Find the parameter values of self where the tangent is equal to angle.
Args:
angle (float): target angle in degrees
Returns:
list[float]: u values between 0.0 and 1.0
"""
angle = angle % 360 # angle needs to always be positive 0..360
u_values: list[float]
if self.geom_type == GeomType.LINE:
if self.tangent_angle_at(0) == angle:
u_values = [0]
else:
u_values = []
else:
# Solve this problem geometrically by creating a tangent curve and finding intercepts
periodic = int(self.is_closed) # if closed don't include end point
tan_pnts: list[VectorLike] = []
previous_tangent = None
# When angles go from 360 to 0 a discontinuity is created so add 360 to these
# values and intercept another line
discontinuities = 0.0
for i in range(101 - periodic):
tangent = self.tangent_angle_at(i / 100) + discontinuities * 360
if (
previous_tangent is not None
and abs(previous_tangent - tangent) > 300
):
discontinuities = copysign(1.0, previous_tangent - tangent)
tangent += 360 * discontinuities
previous_tangent = tangent
tan_pnts.append((i / 100, tangent))
# Generate a first differential curve from the tangent points
tan_curve = Edge.make_spline(tan_pnts)
# Use the bounding box to find the min and max values
tan_curve_bbox = tan_curve.bounding_box()
min_range = 360 * (floor(tan_curve_bbox.min.Y / 360))
max_range = 360 * (ceil(tan_curve_bbox.max.Y / 360))
# Create a horizontal line for each 360 cycle and intercept it
intercept_pnts: list[Vector] = []
for i in range(min_range, max_range + 1, 360):
line = Edge.make_line((0, angle + i, 0), (100, angle + i, 0))
intercept_pnts.extend(tan_curve.find_intersection_points(line))
u_values = [p.X for p in intercept_pnts]
return u_values
def geom_adaptor(self) -> BRepAdaptor_Curve:
"""Return the Geom Curve from this Edge"""
if self._wrapped is None:
raise ValueError("Can't find adaptor for empty edge")
return BRepAdaptor_Curve(self.wrapped)
def _occt_param_at(
self, position: float, position_mode: PositionMode = PositionMode.PARAMETER
) -> tuple[BRepAdaptor_Curve, float, bool]:
"""
Map a position on this edge to its underlying OCCT parameter.
This returns the OCCT `BRepAdaptor_CompCurve` for the edge together with
the corresponding (non-normalized) curve parameter at the given position.
The interpretation of `position` depends on `position_mode`:
- ``PositionMode.PARAMETER``: `position` is a normalized curve parameter in [0, 1].
- ``PositionMode.DISTANCE``: `position` is an arc length distance along the edge.
Edge orientation (`is_forward`) is taken into account so that positions are
measured consistently along the geometric curve.
Args:
position (float): Position along the edge, either a normalized parameter
(0-1) or a distance, depending on `position_mode`.
position_mode (PositionMode, optional): How to interpret `position`.
Defaults to ``PositionMode.PARAMETER``.
Returns:
tuple[BRepAdaptor_CompCurve, float, bool]: The curve adaptor for this edge,
the corresponding OCCT curve parameter and is_forward.
"""
comp_curve = self.geom_adaptor()
length = GCPnts_AbscissaPoint.Length_s(comp_curve)
if position_mode == PositionMode.PARAMETER:
if not self.is_forward:
position = 1 - position
value = position
else:
if not self.is_forward:
position = self.length - position
value = position / self.length
occt_param = GCPnts_AbscissaPoint(
comp_curve, length * value, comp_curve.FirstParameter()
).Parameter()
return comp_curve, occt_param, self.is_forward
def param_at_point(self, point: VectorLike) -> float:
"""
Return the normalized parameter (∈ [0.0, 1.0]) of the location on this edge
closest to `point`.
This method always returns a **normalized** parameter across the edge's full
OCCT parameter range, even though the underlying OCP/OCCT queries work in
native (non-normalized) parameters. It is robust to several OCCT quirks:
1) Vertex snap (fast path)
If `point` coincides (within tolerance) with one of the edge's vertices,
that vertex's OCCT parameter is used and normalized to [0, 1].
Note: for a closed edge, a vertex may represent both start and end; the
mapping is therefore ambiguous and either end may be chosen.
2) Projection via GeomAPI_ProjectPointOnCurve
The OCCT projector's `LowerDistanceParameter()` can legitimately return a
value **outside** the edge's [param_min, param_max] (e.g., periodic curves
or implementation behavior). The result is wrapped back into range using a
modulo by the parameter span and then normalized to [0, 1]. The projected
answer is accepted only if re-evaluating the 3D point at that normalized
parameter is within tolerance of the input `point`.
3) Fallback numeric search (robust path)
If the projector fails the validation, a bounded 1D search is performed
over [0, 1] using progressive subdivision and local minimization of the
3D distance ‖edge(u) - point‖. The first minimum found under geometric
resolution is returned.
Args:
point (VectorLike): A point expected to lie on this edge (within tolerance).
Raises:
ValueError: If `point` is not on the edge within tolerance.
ValueError: Can't find param on empty edge
RuntimeError: If no parameter can be found (e.g., extremely pathological
curves or numerical failure).
Returns:
float: Normalized parameter in [0.0, 1.0] corresponding to the point's
closest location on the edge.
"""
if self._wrapped is None:
raise ValueError("Can't find param on empty edge")
pnt = Vector(point)
# Extract the edge's end parameters
param_min, param_max = BRep_Tool.Range_s(self.wrapped)
param_range = param_max - param_min
# Method 1: the point is a Vertex
# Check to see if the point is a Vertex of the Edge
# Note: on a closed edge a single point is ambiguous so the result
# is undefined with respect to matching the "start" or "end".
nearest_vertex = min(self.vertices(), key=lambda v: (Vector(v) - pnt).length)
if (
Vector(nearest_vertex) - pnt
).length <= TOLERANCE and nearest_vertex.wrapped is not None:
param = BRep_Tool.Parameter_s(nearest_vertex.wrapped, self.wrapped)
return (param - param_min) / param_range
separation = self.distance_to(pnt)
if not isclose_b(separation, 0, abs_tol=TOLERANCE):
raise ValueError(f"point ({pnt}) is {separation} from edge")
# Method 2: project the point onto the edge
# There are known issues with the OCP methods for some
# curves which may return negative values or incorrect values at
# end points.
# Extract the normalized parameter using OCCT GeomAPI_ProjectPointOnCurve
curve = BRep_Tool.Curve_s(self.wrapped, float(), float())
projector = GeomAPI_ProjectPointOnCurve(pnt.to_pnt(), curve)
param = projector.LowerDistanceParameter()
# Note that for some periodic curves the LowerDistanceParameter might
# be outside the given range
curve_adaptor = BRepAdaptor_Curve(self.wrapped)
if curve_adaptor.IsPeriodic():
u_value = ((param - param_min) % curve_adaptor.Period()) / param_range
else:
u_value = (param - param_min) / param_range
# Validate that GeomAPI_ProjectPointOnCurve worked correctly
if (self.position_at(u_value) - pnt).length < TOLERANCE:
return u_value
# Method 3: search the edge for the point
# Note that this search takes about 1.3ms on a complex curve while the
# OCP methods take about 0.4ms.
# This algorithm finds the normalized [0, 1] parameter of a point on an edge
# by minimizing the 3D distance between the edge and the given point.
#
# Because some edges (e.g., BSplines) can have multiple local minima in the
# distance function, we subdivide the [0, 1] domain into 2^n intervals
# (logarithmic refinement) and perform a bounded minimization in each subinterval.
#
# The first solution found with an error smaller than the geometric resolution
# is returned. If no such minimum is found after all subdivisions, a runtime error
# is raised.
max_divisions = 10 # Logarithmic refinement depth
for division in range(max_divisions):
intervals = 2**division
step = 1.0 / intervals
for i in range(intervals):
lo, hi = i * step, (i + 1) * step
result = minimize_scalar(
lambda u: (self.position_at(u) - pnt).length,
bounds=(lo, hi),
method="bounded",
options={"xatol": TOLERANCE / 2},
)
# Early exit if we're below resolution limit
if (
result.fun
< (
self @ (result.x + TOLERANCE) - self @ (result.x - TOLERANCE)
).length
):
return round(float(result.x), TOL_DIGITS)
raise RuntimeError("Unable to find parameter, Edge is too complex")
def project_to_shape(
self,
target_object: Shape,
direction: VectorLike | None = None,
center: VectorLike | None = None,
) -> list[Edge]:
"""Project Edge
Project an Edge onto a Shape generating new wires on the surfaces of the object
one and only one of `direction` or `center` must be provided. Note that one or
more wires may be generated depending on the topology of the target object and
location/direction of projection.
To avoid flipping the normal of a face built with the projected wire the orientation
of the output wires are forced to be the same as self.
Args:
target_object: Object to project onto
direction: Parallel projection direction. Defaults to None.
center: Conical center of projection. Defaults to None.
target_object: Shape:
direction: VectorLike: (Default value = None)
center: VectorLike: (Default value = None)
Returns:
: Projected Edge(s)
Raises:
ValueError: Only one of direction or center must be provided
"""
wire = Wire([self])
projected_wires = wire.project_to_shape(target_object, direction, center)
projected_edges = [w.edges()[0] for w in projected_wires]
return projected_edges
def reversed(self, reconstruct: bool = False) -> Edge:
"""reversed
Return a copy of self with the opposite orientation.
Args:
reconstruct (bool, optional): rebuild edge instead of setting OCCT flag.
Defaults to False.
Returns:
Edge: reversed
"""
if self._wrapped is None:
raise ValueError("An empty edge can't be reversed")
assert isinstance(self.wrapped, TopoDS_Edge)
reversed_edge = copy.deepcopy(self)
if reconstruct:
first: float = self.param_at(0)
last: float = self.param_at(1)
curve = BRep_Tool.Curve_s(self.wrapped, first, last)
first = curve.ReversedParameter(first)
last = curve.ReversedParameter(last)
topods_edge = BRepBuilderAPI_MakeEdge(curve.Reversed(), last, first).Edge()
reversed_edge.wrapped = topods_edge
else:
reversed_edge.wrapped = downcast(self.wrapped.Reversed())
return reversed_edge
def to_axis(self) -> Axis:
"""Translate a linear Edge to an Axis"""
warnings.warn(
"to_axis is deprecated and will be removed in a future version. "
"Use 'Axis(Edge)' instead.",
DeprecationWarning,
stacklevel=2,
)
if self.geom_type != GeomType.LINE:
raise ValueError(
f"to_axis is only valid for linear Edges not {self.geom_type}"
)
return Axis(self.position_at(0), self.position_at(1) - self.position_at(0))
def to_wire(self) -> Wire:
"""Edge as Wire"""
warnings.warn(
"to_wire is deprecated and will be removed in a future version. "
"Use 'Wire(Edge)' instead.",
DeprecationWarning,
stacklevel=2,
)
return Wire([self])
def trim(self, start: float | VectorLike, end: float | VectorLike) -> Edge:
"""_summary_
Args:
start (float | VectorLike): _description_
end (float | VectorLike): _description_
Raises:
TypeError: _description_
ValueError: _description_
Returns:
Edge: _description_
"""
"""trim
Create a new edge by keeping only the section between start and end.
Args:
start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
end (float | VectorLike): 0.0 < end <= 1.0 or point on edge
Raises:
TypeError: invalid input, must be float or VectorLike
ValueError: can't trim empty edge
Returns:
Edge: trimmed edge
"""
start_u = Mixin1D._to_param(self, start, "start")
end_u = Mixin1D._to_param(self, end, "end")
start_u, end_u = sorted([start_u, end_u])
# if start_u >= end_u:
# raise ValueError(f"start ({start_u}) must be less than end ({end_u})")
if self._wrapped is None:
raise ValueError("Can't trim empty edge")
self_copy = copy.deepcopy(self)
assert self_copy.wrapped is not None
new_curve = BRep_Tool.Curve_s(
self_copy.wrapped, self.param_at(0), self.param_at(1)
)
parm_start = self.param_at(start_u)
parm_end = self.param_at(end_u)
trimmed_curve = Geom_TrimmedCurve(
new_curve,
parm_start,
parm_end,
)
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
return Edge(new_edge)
def trim_to_length(self, start: float | VectorLike, length: float) -> Edge:
"""trim_to_length
Create a new edge starting at the given normalized parameter of a
given length.
Args:
start (float | VectorLike): 0.0 <= start < 1.0 or point on edge
length (float): target length
Raise:
ValueError: can't trim empty edge
Returns:
Edge: trimmed edge
"""
if self._wrapped is None:
raise ValueError("Can't trim empty edge")
start_u = Mixin1D._to_param(self, start, "start")
self_copy = copy.deepcopy(self)
assert self_copy.wrapped is not None
new_curve = BRep_Tool.Curve_s(
self_copy.wrapped, self.param_at(0), self.param_at(1)
)
# Create an adaptor for the curve
adaptor_curve = GeomAdaptor_Curve(new_curve)
# Find the parameter corresponding to the desired length
parm_start = self.param_at(start_u)
abscissa_point = GCPnts_AbscissaPoint(adaptor_curve, length, parm_start)
# Get the parameter at the desired length
parm_end = abscissa_point.Parameter()
# Trim the curve to the desired length
trimmed_curve = Geom_TrimmedCurve(new_curve, parm_start, parm_end)
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
return Edge(new_edge)
class Wire(Mixin1D[TopoDS_Wire]):
"""A Wire in build123d is a topological entity representing a connected sequence
of edges forming a continuous curve or path in 3D space. Wires are essential
components in modeling complex objects, defining boundaries for surfaces or
solids. They store information about the connectivity and order of edges,
allowing precise definition of paths within a 3D model."""
order = 1.5
# ---- Constructor ----
@overload
def __init__(
self,
obj: TopoDS_Wire,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build a wire from an OCCT TopoDS_Wire
Args:
obj (TopoDS_Wire, optional): OCCT Wire.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
@overload
def __init__(
self,
edge: Edge,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build a Wire from an Edge
Args:
edge (Edge): Edge to convert to Wire
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
@overload
def __init__(
self,
wire: Wire,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build a Wire from an Wire - used when the input could be an Edge or Wire.
Args:
wire (Wire): Wire to convert to another Wire
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
@overload
def __init__(
self,
wire: Curve,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build a Wire from an Curve.
Args:
curve (Curve): Curve to convert to a Wire
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
@overload
def __init__(
self,
edges: Iterable[Edge],
sequenced: bool = False,
label: str = "",
color: Color | None = None,
parent: Compound | None = None,
):
"""Build a wire from Edges
Build a Wire from the provided unsorted Edges. If sequenced is True the
Edges are placed in such that the end of the nth Edge is coincident with
the n+1th Edge forming an unbroken sequence. Note that sequencing a list
is relatively slow.
Args:
edges (Iterable[Edge]): Edges to assemble
sequenced (bool, optional): arrange in order. Defaults to False.
label (str, optional): Defaults to ''.
color (Color, optional): Defaults to None.
parent (Compound, optional): assembly parent. Defaults to None.
"""
def __init__(self, *args, **kwargs):
curve, edge, edges, wire, sequenced, obj, label, color, parent = (None,) * 9
if args:
l_a = len(args)
if isinstance(args[0], TopoDS_Wire):
obj, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Edge):
edge, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Wire):
wire, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif (
hasattr(args[0], "wrapped")
and isinstance(args[0].wrapped, TopoDS_Compound)
and topods_dim(args[0].wrapped) == 1
): # Curve
curve, label, color, parent = args[:4] + (None,) * (4 - l_a)
elif isinstance(args[0], Iterable):
edges, sequenced, label, color, parent = args[:5] + (None,) * (5 - l_a)
unknown_args = ", ".join(
set(kwargs.keys()).difference(
[
"curve",
"wire",
"edge",
"edges",
"sequenced",
"obj",
"label",
"color",
"parent",
]
)
)
if unknown_args:
raise ValueError(f"Unexpected argument(s) {unknown_args}")
obj = kwargs.get("obj", obj)
edge = kwargs.get("edge", edge)
edges = kwargs.get("edges", edges)
sequenced = kwargs.get("sequenced", sequenced)
label = kwargs.get("label", label)
color = kwargs.get("color", color)
parent = kwargs.get("parent", parent)
wire = kwargs.get("wire", wire)
curve = kwargs.get("curve", curve)
if edge is not None:
edges = [edge]
elif curve is not None:
edges = curve.edges()
if wire is not None:
obj = wire.wrapped
elif edges:
obj = Wire._make_wire(edges, False if sequenced is None else sequenced)
super().__init__(
obj=obj,
label="" if label is None else label,
color=color,
parent=parent,
)
# ---- Class Methods ----
@classmethod
def _make_wire(cls, edges: Iterable[Edge], sequenced: bool = False) -> TopoDS_Wire:
"""_make_wire
Build a Wire from the provided unsorted Edges. If sequenced is True the
Edges are placed in such that the end of the nth Edge is coincident with
the n+1th Edge forming an unbroken sequence. Note that sequencing a list
is relatively slow.
Args:
edges (Iterable[Edge]): Edges to assemble
sequenced (bool, optional): arrange in order. Defaults to False.
Raises:
ValueError: Edges are disconnected and can't be sequenced.
RuntimeError: Wire is empty
Returns:
Wire: assembled edges
"""
def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge:
"""Return the Edge closest to the end of last_edge"""
target_point = current.position_at(1)
sorted_edges = sorted(
unplaced_edges,
key=lambda e: min(
(target_point - e.position_at(0)).length,
(target_point - e.position_at(1)).length,
),
)
return sorted_edges[0]
edges = list(edges)
if sequenced:
placed_edges = [edges.pop(0)]
unplaced_edges = edges
while unplaced_edges:
next_edge = closest_to_end(Wire(placed_edges), unplaced_edges)
next_edge_index = unplaced_edges.index(next_edge)
placed_edges.append(unplaced_edges.pop(next_edge_index))
edges = placed_edges
wire_builder = BRepBuilderAPI_MakeWire()
combined_edges = TopTools_ListOfShape()
for edge in edges:
if edge.wrapped is not None:
combined_edges.Append(edge.wrapped)
wire_builder.Add(combined_edges)
wire_builder.Build()
if not wire_builder.IsDone():
if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire:
warnings.warn(
"Wire is non manifold (e.g. branching, self intersecting)",
stacklevel=2,
)
elif wire_builder.Error() == BRepBuilderAPI_EmptyWire:
raise RuntimeError("Wire is empty")
elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire:
raise ValueError("Edges are disconnected")
return wire_builder.Wire()
@classmethod
def combine(
cls, wires: Iterable[Wire | Edge], tol: float = 1e-9
) -> ShapeList[Wire]:
"""combine
Combine a list of wires and edges into a list of Wires.
Args:
wires (Iterable[Wire | Edge]): unsorted
tol (float, optional): tolerance. Defaults to 1e-9.
Returns:
ShapeList[Wire]: Wires
"""
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
for edge in [e for w in wires for e in w.edges()]:
if edge.wrapped is not None:
edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
wires = ShapeList()
for i in range(wires_out.Length()):
wires.append(Wire(tcast(TopoDS_Wire, downcast(wires_out.Value(i + 1)))))
return wires
@classmethod
def extrude(cls, obj: Shape, direction: VectorLike) -> Wire:
"""extrude - invalid operation for Wire"""
raise NotImplementedError("Wires can't be created by extrusion")
@classmethod
def make_circle(cls, radius: float, plane: Plane = Plane.XY) -> Wire:
"""make_circle
Makes a circle centered at the origin of plane
Args:
radius (float): circle radius
plane (Plane): base plane. Defaults to Plane.XY
Returns:
Wire: a circle
"""
circle_edge = Edge.make_circle(radius, plane=plane)
return Wire([circle_edge])
@classmethod
def make_convex_hull(cls, edges: Iterable[Edge], tolerance: float = 1e-3) -> Wire:
"""make_convex_hull
Create a wire of minimum length enclosing all of the provided edges.
Note that edges can't overlap each other.
Args:
edges (Iterable[Edge]): edges defining the convex hull
tolerance (float): allowable error as a fraction of each edge length.
Defaults to 1e-3.
Raises:
ValueError: edges overlap
Returns:
Wire: convex hull perimeter
"""
# pylint: disable=too-many-branches, too-many-locals
# Algorithm:
# 1) create a cloud of points along all edges
# 2) create a convex hull which returns facets/simplices as pairs of point indices
# 3) find facets that are within an edge but not adjacent and store trim and
# new connecting edge data
# 4) find facets between edges and store trim and new connecting edge data
# 5) post process the trim data to remove duplicates and store in pairs
# 6) create connecting edges
# 7) create trim edges from the original edges and the trim data
# 8) return a wire version of all the edges
# Possible enhancement: The accuracy of the result could be improved and the
# execution time reduced by adaptively placing more points around where the
# connecting edges contact the arc.
# if any(
# [
# edge_pair[0].overlaps(edge_pair[1])
# for edge_pair in combinations(edges, 2)
# ]
# ):
# raise ValueError("edges overlap")
edges = list(edges)
fragments_per_edge = int(2 / tolerance)
points_lookup = {} # lookup from point index to edge/position on edge
points = [] # convex hull point cloud
# Create points along each edge and the lookup structure
for edge_index, edge in enumerate(edges):
for i in range(fragments_per_edge):
param = i / (fragments_per_edge - 1)
points.append(tuple(edge.position_at(param))[:2])
points_lookup[edge_index * fragments_per_edge + i] = (edge_index, param)
convex_hull = ConvexHull(points)
# Filter the fragments
connecting_edge_data = []
trim_points: dict[int, list[int]] = {}
for simplice in convex_hull.simplices:
edge0 = points_lookup[simplice[0]][0]
edge1 = points_lookup[simplice[1]][0]
# Look for connecting edges between edges
if edge0 != edge1:
if edge0 not in trim_points:
trim_points[edge0] = [simplice[0]]
else:
trim_points[edge0].append(simplice[0])
if edge1 not in trim_points:
trim_points[edge1] = [simplice[1]]
else:
trim_points[edge1].append(simplice[1])
connecting_edge_data.append(
(
(edge0, points_lookup[simplice[0]][1], simplice[0]),
(edge1, points_lookup[simplice[1]][1], simplice[1]),
)
)
# Look for connecting edges within an edge
elif abs(simplice[0] - simplice[1]) != 1:
start_pnt = min(simplice.tolist())
end_pnt = max(simplice.tolist())
if edge0 not in trim_points:
trim_points[edge0] = [start_pnt, end_pnt]
else:
trim_points[edge0].extend([start_pnt, end_pnt])
connecting_edge_data.append(
(
(edge0, points_lookup[start_pnt][1], start_pnt),
(edge0, points_lookup[end_pnt][1], end_pnt),
)
)
trim_data = {}
for edge_index, start_end_pnts in trim_points.items():
s_points = sorted(start_end_pnts)
f_points = []
for i in range(0, len(s_points) - 1, 2):
if s_points[i] != s_points[i + 1]:
f_points.append(tuple(s_points[i : i + 2]))
trim_data[edge_index] = f_points
connecting_edges = [
Edge.make_line(
edges[line[0][0]] @ line[0][1], edges[line[1][0]] @ line[1][1]
)
for line in connecting_edge_data
]
trimmed_edges = [
edges[edge_index].trim(
points_lookup[trim_pair[0]][1], points_lookup[trim_pair[1]][1]
)
for edge_index, trim_pairs in trim_data.items()
for trim_pair in trim_pairs
]
hull_wire = Wire(connecting_edges + trimmed_edges, sequenced=True)
return hull_wire
@classmethod
def make_ellipse(
cls,
x_radius: float,
y_radius: float,
plane: Plane = Plane.XY,
start_angle: float = 360.0,
end_angle: float = 360.0,
angular_direction: AngularDirection = AngularDirection.COUNTER_CLOCKWISE,
closed: bool = True,
) -> Wire:
"""make ellipse
Makes an ellipse centered at the origin of plane.
Args:
x_radius (float): x radius of the ellipse (along the x-axis of plane)
y_radius (float): y radius of the ellipse (along the y-axis of plane)
plane (Plane, optional): base plane. Defaults to Plane.XY.
start_angle (float, optional): _description_. Defaults to 360.0.
end_angle (float, optional): _description_. Defaults to 360.0.
angular_direction (AngularDirection, optional): arc direction.
Defaults to AngularDirection.COUNTER_CLOCKWISE.
closed (bool, optional): close the arc. Defaults to True.
Returns:
Wire: an ellipse
"""
ellipse_edge = Edge.make_ellipse(
x_radius, y_radius, plane, start_angle, end_angle, angular_direction
)
if start_angle != end_angle and closed:
line = Edge.make_line(ellipse_edge.end_point(), ellipse_edge.start_point())
wire = Wire([ellipse_edge, line])
else:
wire = Wire([ellipse_edge])
return wire
@classmethod
def make_polygon(cls, vertices: Iterable[VectorLike], close: bool = True) -> Wire:
"""make_polygon
Create an irregular polygon by defining vertices
Args:
vertices (Iterable[VectorLike]):
close (bool, optional): close the polygon. Defaults to True.
Returns:
Wire: an irregular polygon
"""
vectors = [Vector(v) for v in vertices]
if (vectors[0] - vectors[-1]).length > TOLERANCE and close:
vectors.append(vectors[0])
wire_builder = BRepBuilderAPI_MakePolygon()
for vertex in vectors:
wire_builder.Add(vertex.to_pnt())
return cls(wire_builder.Wire())
@classmethod
def make_rect(
cls,
width: float,
height: float,
plane: Plane = Plane.XY,
) -> Wire:
"""Make Rectangle
Make a Rectangle centered on center with the given normal
Args:
width (float): width (local x)
height (float): height (local y)
plane (Plane, optional): plane containing rectangle. Defaults to Plane.XY.
Returns:
Wire: The centered rectangle
"""
corners_local = [
(width / 2, height / 2),
(width / 2, height / -2),
(width / -2, height / -2),
(width / -2, height / 2),
]
corners_world = [plane.from_local_coords(c) for c in corners_local]
return Wire.make_polygon(corners_world, close=True)
# ---- Static Methods ----
@staticmethod
def order_chamfer_edges(
reference_edge: Edge | None, edges: tuple[Edge, Edge]
) -> tuple[Edge, Edge]:
"""Order the edges of a chamfer relative to a reference Edge"""
if reference_edge:
edge1, edge2 = edges
if edge1 == reference_edge:
return edge1, edge2
if edge2 == reference_edge:
return edge2, edge1
raise ValueError("reference edge not in edges")
return edges
# ---- Instance Methods ----
def chamfer_2d(
self,
distance: float,
distance2: float,
vertices: Iterable[Vertex],
edge: Edge | None = None,
) -> Wire:
"""chamfer_2d
Apply 2D chamfer to a wire
Args:
distance (float): chamfer length
distance2 (float): chamfer length
vertices (Iterable[Vertex]): vertices to chamfer
edge (Edge): identifies the side where length is measured. The vertices must be
part of the edge
Returns:
Wire: chamfered wire
"""
if self._wrapped is None:
raise ValueError("Can't chamfer empty wire")
reference_edge = edge
# Create a face to chamfer
unchamfered_face = _make_topods_face_from_wires(self.wrapped)
chamfer_builder = BRepFilletAPI_MakeFillet2d(unchamfered_face)
vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
unchamfered_face, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map
)
for v in vertices:
if not v:
continue
edge_list = vertex_edge_map.FindFromKey(v.wrapped)
# Index or iterator access to OCP.TopTools.TopTools_ListOfShape is slow on M1 macs
# Using First() and Last() to omit
edges = (
Edge(tcast(TopoDS_Edge, downcast(edge_list.First()))),
Edge(tcast(TopoDS_Edge, downcast(edge_list.Last()))),
)
edge1, edge2 = Wire.order_chamfer_edges(reference_edge, edges)
if edge1.wrapped is not None and edge2.wrapped is not None:
chamfer_builder.AddChamfer(
TopoDS.Edge_s(edge1.wrapped),
TopoDS.Edge_s(edge2.wrapped),
distance,
distance2,
)
chamfer_builder.Build()
chamfered_face = chamfer_builder.Shape()
# Fix the shape
shape_fix = ShapeFix_Shape(chamfered_face)
shape_fix.Perform()
chamfered_face = downcast(shape_fix.Shape())
if not isinstance(chamfered_face, TopoDS_Face):
raise RuntimeError("An internal error occured creating the chamfer")
# Return the outer wire
return Wire(BRepTools.OuterWire_s(chamfered_face))
def close(self) -> Wire:
"""Close a Wire"""
if not self.is_closed:
edge = Edge.make_line(self.end_point(), self.start_point())
return_value = Wire.combine((self, edge))[0]
else:
return_value = self
return return_value
def fillet_2d(self, radius: float, vertices: Iterable[Vertex]) -> Wire:
"""fillet_2d
Apply 2D fillet to a wire
Args:
radius (float):
vertices (Iterable[Vertex]): vertices to fillet
Raises:
RuntimeError: Internal error
ValueError: empty wire
Returns:
Wire: filleted wire
"""
if self._wrapped is None:
raise ValueError("Can't fillet an empty wire")
# Create a face to fillet
unfilleted_face = _make_topods_face_from_wires(self.wrapped)
# Fillet the face
fillet_builder = BRepFilletAPI_MakeFillet2d(unfilleted_face)
for vertex in vertices:
if vertex.wrapped is not None:
fillet_builder.AddFillet(vertex.wrapped, radius)
fillet_builder.Build()
filleted_face = downcast(fillet_builder.Shape())
if not isinstance(filleted_face, TopoDS_Face):
raise RuntimeError("An internal error occured creating the fillet")
# Return the outer wire
return Wire(BRepTools.OuterWire_s(filleted_face))
def fix_degenerate_edges(self, precision: float) -> Wire:
"""fix_degenerate_edges
Fix a Wire that contains degenerate (very small) edges
Args:
precision (float): minimum value edge length
Returns:
Wire: fixed wire
"""
if self._wrapped is None:
raise ValueError("Can't fix an empty edge")
sf_w = ShapeFix_Wireframe(self.wrapped)
sf_w.SetPrecision(precision)
sf_w.SetMaxTolerance(1e-6)
sf_w.FixSmallEdges()
sf_w.FixWireGaps()
return Wire(tcast(TopoDS_Wire, downcast(sf_w.Shape())))
def geom_adaptor(self) -> BRepAdaptor_CompCurve:
"""Return the Geom Comp Curve for this Wire"""
if self._wrapped is None:
raise ValueError("Can't get geom adaptor of empty wire")
return BRepAdaptor_CompCurve(self.wrapped)
def order_edges(self) -> ShapeList[Edge]:
"""Return the edges in self ordered by wire direction and orientation"""
sorted_edges = self.edges().sort_by(self)
ordered_edges = ShapeList([sorted_edges[0]])
for edge in sorted_edges[1:]:
last_edge = ordered_edges[-1]
if abs(last_edge @ 1 - edge @ 0) < TOLERANCE:
ordered_edges.append(edge)
else:
ordered_edges.append(edge.reversed())
return ordered_edges
def param_at_point(self, point: VectorLike) -> float:
"""
Return the normalized wire parameter for the point closest to this wire.
This method projects the given point onto the wire, finds the nearest edge,
and accumulates arc lengths to determine the fractional position along the
entire wire. The result is normalized to the interval [0.0, 1.0], where:
- 0.0 corresponds to the start of the wire
- 1.0 corresponds to the end of the wire
Unlike the edge version of this method, the returned value is **not**
an OCCT curve parameter, but a normalized parameter across the wire as a whole.
Args:
point (VectorLike): The point to project onto the wire.
Raises:
ValueError: Can't find point on empty wire
Returns:
float: Normalized parameter in [0.0, 1.0] representing the relative
position of the projected point along the wire.
"""
if self._wrapped is None:
raise ValueError("Can't find point on empty wire")
point_on_curve = Vector(point)
vertex_on_curve = Vertex(point_on_curve)
assert vertex_on_curve.wrapped is not None
separation = self.distance_to(point)
if not isclose_b(separation, 0, abs_tol=TOLERANCE):
raise ValueError(f"point ({point}) is {separation} from wire")
extrema = BRepExtrema_DistShapeShape(vertex_on_curve.wrapped, self.wrapped)
extrema.Perform()
if not extrema.IsDone() or extrema.NbSolution() == 0:
raise ValueError("point is not on Wire")
supp_type = extrema.SupportTypeShape2(1)
if supp_type == BRepExtrema_SupportType.BRepExtrema_IsOnEdge:
closest_topods_edge = tcast(
TopoDS_Edge, downcast(extrema.SupportOnShape2(1))
)
closest_topods_edge_param = extrema.ParOnEdgeS2(1)[0]
elif supp_type == BRepExtrema_SupportType.BRepExtrema_IsVertex:
v_hit = tcast(TopoDS_Vertex, downcast(extrema.SupportOnShape2(1)))
vertex_edge_map = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
self.wrapped, ta.TopAbs_VERTEX, ta.TopAbs_EDGE, vertex_edge_map
)
closest_topods_edge = tcast(
TopoDS_Edge, downcast(vertex_edge_map.FindFromKey(v_hit).First())
)
closest_topods_edge_param = BRep_Tool.Parameter_s(
v_hit, closest_topods_edge
)
curve_adaptor = BRepAdaptor_Curve(closest_topods_edge)
param_min, param_max = BRep_Tool.Range_s(closest_topods_edge)
if curve_adaptor.IsPeriodic():
closest_topods_edge_param = (
(closest_topods_edge_param - param_min) % curve_adaptor.Period()
) + param_min
param_pair = (
(param_min, closest_topods_edge_param)
if closest_topods_edge.Orientation() == TopAbs_Orientation.TopAbs_FORWARD
else (closest_topods_edge_param, param_max)
)
distance_along_wire = GCPnts_AbscissaPoint.Length_s(curve_adaptor, *param_pair)
# Find all of the edges prior to the closest edge
wire_explorer = BRepTools_WireExplorer(self.wrapped)
while wire_explorer.More():
topods_edge = wire_explorer.Current()
# Skip degenerate edges
if BRep_Tool.Degenerated_s(topods_edge):
wire_explorer.Next()
continue
# Stop when we find the closest edge
if topods_edge.IsEqual(closest_topods_edge):
break
# Add the length of the current edge to the running total
distance_along_wire += GCPnts_AbscissaPoint.Length_s(
BRepAdaptor_Curve(topods_edge)
)
wire_explorer.Next()
return distance_along_wire / self.length
def _occt_param_at(
self, position: float, position_mode: PositionMode = PositionMode.PARAMETER
) -> tuple[BRepAdaptor_Curve, float, bool]:
"""
Map a position along this wire to the underlying OCCT edge and curve parameter.
Unlike the edge version, this method determines which constituent edge of the
wire contains the requested position, then returns a curve adaptor for that
edge together with the corresponding OCCT parameter.
The interpretation of `position` depends on `position_mode`:
- ``PositionMode.PARAMETER``: `position` is a normalized parameter in [0, 1]
across the entire wire.
- ``PositionMode.DISTANCE``: `position` is an arc length distance along the wire.
Edge and wire orientation (`is_forward`) is respected so that positions are
measured consistently along the wire.
Args:
position (float): Position along the wire, either a normalized parameter
(0-1) or a distance, depending on `position_mode`.
position_mode (PositionMode, optional): How to interpret `position`.
Defaults to ``PositionMode.PARAMETER``.
Returns:
tuple[BRepAdaptor_Curve, float]: The curve adaptor for the specific edge
at the given position, the corresponding OCCT parameter on that edge and
if edge is_forward.
"""
wire_curve_adaptor = self.geom_adaptor()
if position_mode == PositionMode.PARAMETER:
if not self.is_forward:
position = 1 - position
occt_wire_param = self.param_at(position)
else:
if not self.is_forward:
position = self.length - position
occt_wire_param = self.param_at(position / self.length)
topods_edge_at_position = TopoDS_Edge()
occt_edge_params = wire_curve_adaptor.Edge(
occt_wire_param, topods_edge_at_position
)
edge_curve_adaptor = BRepAdaptor_Curve(topods_edge_at_position)
return (
edge_curve_adaptor,
occt_edge_params[0],
topods_edge_at_position.Orientation() == TopAbs_Orientation.TopAbs_FORWARD,
)
def project_to_shape(
self,
target_object: Shape,
direction: VectorLike | None = None,
center: VectorLike | None = None,
) -> list[Wire]:
"""Project Wire
Project a Wire onto a Shape generating new wires on the surfaces of the object
one and only one of `direction` or `center` must be provided. Note that one or
more wires may be generated depending on the topology of the target object and
location/direction of projection.
To avoid flipping the normal of a face built with the projected wire the orientation
of the output wires are forced to be the same as self.
Args:
target_object: Object to project onto
direction: Parallel projection direction. Defaults to None.
center: Conical center of projection. Defaults to None.
target_object: Shape:
direction: VectorLike: (Default value = None)
center: VectorLike: (Default value = None)
Returns:
: Projected wire(s)
Raises:
ValueError: Only one of direction or center must be provided
"""
# pylint: disable=too-many-branches
if self._wrapped is None or not target_object:
raise ValueError("Can't project empty Wires or to empty Shapes")
if direction is not None and center is None:
direction_vector = Vector(direction).normalized()
center_point = Vector() # for typing, never used
elif center is not None and direction is None:
direction_vector = None
center_point = Vector(center)
else:
raise ValueError("Provide exactly one of direction or center")
# Project the wire on the target object
if direction_vector is not None:
projection_object = BRepProj_Projection(
self.wrapped,
target_object.wrapped,
gp_Dir(*direction_vector),
)
else:
projection_object = BRepProj_Projection(
self.wrapped,
target_object.wrapped,
gp_Pnt(*center_point),
)
# Generate a list of the projected wires with aligned orientation
output_wires = []
target_orientation = self.wrapped.Orientation()
while projection_object.More():
projected_wire = projection_object.Current()
if target_orientation == projected_wire.Orientation():
output_wires.append(Wire(projected_wire))
else:
output_wires.append(
Wire(tcast(TopoDS_Wire, downcast(projected_wire.Reversed())))
)
projection_object.Next()
logger.debug("wire generated %d projected wires", len(output_wires))
# BRepProj_Projection is inconsistent in the order that it returns projected
# wires, sometimes front first and sometimes back - so sort this out by sorting
# by distance from the original planar wire
if len(output_wires) > 1:
output_wires_distances = []
planar_wire_center = self.center()
for output_wire in output_wires:
output_wire_center = output_wire.center()
if direction_vector is not None:
output_wire_direction = (
output_wire_center - planar_wire_center
).normalized()
if output_wire_direction.dot(direction_vector) >= 0:
output_wires_distances.append(
(
output_wire,
(output_wire_center - planar_wire_center).length,
)
)
else:
output_wires_distances.append(
(
output_wire,
(output_wire_center - center_point).length,
)
)
output_wires_distances.sort(key=lambda x: x[1])
logger.debug(
"projected, filtered and sorted wire list is of length %d",
len(output_wires_distances),
)
output_wires = [w[0] for w in output_wires_distances]
return output_wires
def stitch(self, other: Wire) -> Wire:
"""Attempt to stitch wires
Args:
other (Wire): wire to combine
Raises:
ValueError: Can't stitch empty wires
Returns:
Wire: stitched wires
"""
if self._wrapped is None or not other:
raise ValueError("Can't stitch empty wires")
wire_builder = BRepBuilderAPI_MakeWire()
wire_builder.Add(TopoDS.Wire_s(self.wrapped))
wire_builder.Add(TopoDS.Wire_s(other.wrapped))
wire_builder.Build()
return self.__class__.cast(wire_builder.Wire())
def _to_bspline(self) -> Edge:
"""
Collapse this wire into a single BSpline edge (internal use).
Concatenates the wire's constituent edges—**in topological order**—into one
`Geom_BSplineCurve` using OCP/OCCT's `GeomConvert_CompCurveToBSplineCurve`.
Degenerate edges are skipped. The resulting topology is a **single Edge**;
former junctions between original edges become **internal spline knots**
(C0 corners) but **not vertices**.
⚠️ Not intended for general user workflows. The loss of vertex boundaries
can make downstream operations (e.g., splitting at vertices, continuity checks,
feature recognition) surprising. This is primarily useful for internal tasks
that benefit from a single-curve representation (e.g., length/abscissa queries
or parameter mapping along the entire wire).
Behavior & caveats:
- Orientation and section order follow the wire's topological sequence.
- Junctions with only C0 continuity are preserved as spline knots, not as
topological vertices.
- The returned edge's parameterization is that of the composite BSpline
(not a normalized [0,1] wire parameter).
- Failure to append any segment or to build the final edge raises an error.
Raises:
RuntimeError: If any segment cannot be appended to the composite spline
or the final BSpline edge cannot be built.
ValueError: Empty Wire
Returns:
Edge: A single edge whose geometry is `GeomType.BSPLINE`.
"""
# Build a single Geom_BSplineCurve from the wire, in *topological order*
builder = GeomConvert_CompCurveToBSplineCurve()
if self._wrapped is None:
raise ValueError("Can't convert an empty wire")
wire_explorer = BRepTools_WireExplorer(self.wrapped)
while wire_explorer.More():
topods_edge = wire_explorer.Current()
# Skip degenerate edges
if BRep_Tool.Degenerated_s(topods_edge):
wire_explorer.Next()
continue
param_min, param_max = BRep_Tool.Range_s(topods_edge)
new_curve = BRep_Tool.Curve_s(topods_edge, float(), float())
trimmed_curve = Geom_TrimmedCurve(new_curve, param_min, param_max)
# Append this edge's trimmed curve into the composite spline.
ok = builder.Add(trimmed_curve, TOLERANCE)
if not ok:
raise RuntimeError("Failed to build bspline.")
wire_explorer.Next()
edge_builder = BRepBuilderAPI_MakeEdge(builder.BSplineCurve())
if not edge_builder.IsDone():
raise RuntimeError("Failed to build bspline.")
return Edge(edge_builder.Edge())
def to_wire(self) -> Wire:
"""Return Wire - used as a pair with Edge.to_wire when self is Wire | Edge"""
warnings.warn(
"to_wire is deprecated and will be removed in a future version. "
"Use 'Wire(Wire)' instead.",
DeprecationWarning,
stacklevel=2,
)
return self
def trim(self: Wire, start: float | VectorLike, end: float | VectorLike) -> Wire:
"""Trim a wire between [start, end] normalized over total length.
Args:
start (float | VectorLike): normalized start position (0.0 to <1.0) or point
end (float | VectorLike): normalized end position (>0.0 to 1.0) or point
Returns:
Wire: trimmed Wire
"""
start_u = Mixin1D._to_param(self, start, "start")
end_u = Mixin1D._to_param(self, end, "end")
start_u, end_u = sorted([start_u, end_u])
# Extract the edges in order
ordered_edges = self.edges().sort_by(self)
# If this is really just an edge, skip the complexity of a Wire
if len(ordered_edges) == 1:
return Wire([ordered_edges[0].trim(start_u, end_u)])
total_length = self.length
start_len = start_u * total_length
end_len = end_u * total_length
trimmed_edges = []
cur_length = 0.0
for edge in ordered_edges:
edge_len = edge.length
edge_start = cur_length
edge_end = cur_length + edge_len
cur_length = edge_end
if edge_end <= start_len or edge_start >= end_len:
continue # skip
if edge_start >= start_len and edge_end <= end_len:
trimmed_edges.append(edge) # keep whole Edge
else:
# Normalize trim points relative to this edge
trim_start_len = max(start_len, edge_start)
trim_end_len = min(end_len, edge_end)
u0 = (trim_start_len - edge_start) / edge_len
u1 = (trim_end_len - edge_start) / edge_len
if abs(u1 - u0) > TOLERANCE:
trimmed_edges.append(edge.trim(u0, u1))
return Wire(trimmed_edges)
def edges_to_wires(edges: Iterable[Edge], tol: float = 1e-6) -> ShapeList[Wire]:
"""Convert edges to a list of wires.
Args:
edges: Iterable[Edge]:
tol: float: (Default value = 1e-6)
Returns:
"""
edges_in = TopTools_HSequenceOfShape()
wires_out = TopTools_HSequenceOfShape()
for edge in edges:
if edge.wrapped is not None:
edges_in.Append(edge.wrapped)
ShapeAnalysis_FreeBounds.ConnectEdgesToWires_s(edges_in, tol, False, wires_out)
wires: ShapeList[Wire] = ShapeList()
for i in range(wires_out.Length()):
# wires.append(Wire(downcast(wires_out.Value(i + 1))))
wires.append(Wire(TopoDS.Wire_s(wires_out.Value(i + 1))))
return wires
def offset_topods_face(face: TopoDS_Face, amount: float) -> TopoDS_Shape:
"""Offset a topods_face"""
offsetor = BRepOffset_MakeOffset()
offsetor.Initialize(face, Offset=amount, Tol=TOLERANCE)
offsetor.MakeOffsetShape()
return offsetor.Shape()
def topo_explore_connected_edges(
edge: Edge,
parent: Shape | None = None,
continuity: ContinuityLevel = ContinuityLevel.C0,
) -> ShapeList[Edge]:
"""
Find edges connected to the given edge with at least the requested continuity.
Args:
edge: The reference edge to explore from.
parent: Optional parent Shape. If None, uses edge.topo_parent.
continuity: Minimum required continuity (C0/G0, C1/G1, C2/G2).
Returns:
ShapeList[Edge]: Connected edges meeting the continuity requirement.
"""
continuity_map = {
GeomAbs_C0: ContinuityLevel.C0,
GeomAbs_G1: ContinuityLevel.C1,
GeomAbs_C1: ContinuityLevel.C1,
GeomAbs_G2: ContinuityLevel.C2,
GeomAbs_C2: ContinuityLevel.C2,
}
parent = parent if parent is not None else edge.topo_parent
if parent is None:
raise ValueError("edge has no valid parent")
if not edge:
raise ValueError("edge is empty")
given_topods_edge = edge.wrapped
connected_edges = set()
# Find all the TopoDS_Edges for this Shape
topods_edges = [e.wrapped for e in parent.edges() if e.wrapped is not None]
for topods_edge in topods_edges:
# # Don't match with the given edge
if given_topods_edge.IsSame(topods_edge):
continue
# If the edge shares a vertex with the given edge they are connected
common_topods_vertex: Vertex | None = topo_explore_common_vertex(
given_topods_edge, topods_edge
)
if (
common_topods_vertex is not None
and common_topods_vertex.wrapped is not None
):
# shared_vertex is the TopoDS_Vertex common to edge1 and edge2
u1 = BRep_Tool.Parameter_s(common_topods_vertex.wrapped, given_topods_edge)
u2 = BRep_Tool.Parameter_s(common_topods_vertex.wrapped, topods_edge)
# Build adaptors so OCCT can work on the curves
curve1 = BRepAdaptor_Curve(given_topods_edge)
curve2 = BRepAdaptor_Curve(topods_edge)
# Get the GeomAbs_Shape enum continuity at the vertex
actual_continuity = BRepLProp.Continuity_s(
curve1, curve2, u1, u2, TOLERANCE, TOLERANCE
)
actual_level = continuity_map.get(actual_continuity, ContinuityLevel.C2)
if actual_level >= continuity:
connected_edges.add(topods_edge)
return ShapeList(Edge(e) for e in connected_edges)
def topo_explore_connected_faces(
edge: Edge, parent: Shape | None = None
) -> list[TopoDS_Face]:
"""Given an edge extracted from a Shape, return the topods_faces connected to it"""
if not edge:
raise ValueError("Can't explore from an empty edge")
parent = parent if parent is not None else edge.topo_parent
if not parent:
raise ValueError("edge has no valid parent")
# make a edge --> faces mapping
edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape()
TopExp.MapShapesAndAncestors_s(
parent.wrapped, ta.TopAbs_EDGE, ta.TopAbs_FACE, edge_face_map
)
# Query the map and select only unique faces
unique_face_map = TopTools_IndexedMapOfShape()
unique_faces = []
if edge_face_map.Contains(edge.wrapped):
for face in edge_face_map.FindFromKey(edge.wrapped):
unique_face_map.Add(face)
for i in range(unique_face_map.Extent()):
unique_faces.append(TopoDS.Face_s(unique_face_map(i + 1)))
return unique_faces