mirror of
https://github.com/gumyr/build123d.git
synced 2025-12-06 02:30:55 -08:00
4267 lines
154 KiB
Python
4267 lines
154 KiB
Python
"""
|
||
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
|