5353
5454import copy
5555import warnings
56+ from bisect import bisect_right
5657from collections .abc import Iterable , Sequence
5758from itertools import combinations
5859from math import atan2 , ceil , copysign , cos , floor , inf , isclose , pi , radians
8889from OCP .BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
8990from OCP .BRepProj import BRepProj_Projection
9091from OCP .BRepTools import BRepTools , BRepTools_WireExplorer
92+ from OCP .Extrema import Extrema_ExtPC
9193from OCP .GC import (
9294 GC_MakeArcOfCircle ,
9395 GC_MakeArcOfEllipse ,
9799from OCP .GCPnts import (
98100 GCPnts_AbscissaPoint ,
99101 GCPnts_QuasiUniformDeflection ,
102+ GCPnts_TangentialDeflection ,
100103 GCPnts_UniformDeflection ,
101104)
102105from OCP .GProp import GProp_GProps
@@ -756,13 +759,9 @@ def _intersect(
756759
757760 # 1D + 1D: Common (collinear overlap) + Section (crossing vertices)
758761 elif isinstance (other , (Edge , Wire )):
759- common = self ._bool_op_list (
760- (self ,), (other ,), BRepAlgoAPI_Common ()
761- )
762+ common = self ._bool_op_list ((self ,), (other ,), BRepAlgoAPI_Common ())
762763 results .extend (common .expand ())
763- section = self ._bool_op_list (
764- (self ,), (other ,), BRepAlgoAPI_Section ()
765- )
764+ section = self ._bool_op_list ((self ,), (other ,), BRepAlgoAPI_Section ())
766765 # Extract vertices from section (edges already in Common for wires)
767766 for shape in section :
768767 if isinstance (shape , Vertex ) and not shape .is_null :
@@ -786,7 +785,6 @@ def location_at(
786785 distance : float ,
787786 position_mode : PositionMode = PositionMode .PARAMETER ,
788787 frame_method : FrameMethod = FrameMethod .FRENET ,
789- planar : bool | None = None ,
790788 x_dir : VectorLike | None = None ,
791789 ) -> Location :
792790 """Locations along curve
@@ -802,15 +800,10 @@ def location_at(
802800 spots. The CORRECTED frame behaves more like a “camera dolly” or
803801 sweep profile would — it's smoother and more stable.
804802 Defaults to FrameMethod.FRENET.
805- planar (bool, optional): planar mode. Defaults to None.
806803 x_dir (VectorLike, optional): override the x_dir to help with plane
807- creation along a 1D shape. Must be perpendicalar to shapes tangent.
804+ creation along a 1D shape. Must be perpendicular to shapes tangent.
808805 Defaults to None.
809806
810- .. deprecated::
811- The `planar` parameter is deprecated and will be removed in a future release.
812- Use `x_dir` to specify orientation instead.
813-
814807 Returns:
815808 Location: A Location object representing local coordinate system
816809 at the specified distance.
@@ -823,10 +816,25 @@ def location_at(
823816 else :
824817 distance = self .length - distance
825818
826- if position_mode == PositionMode .PARAMETER :
827- param = self .param_at (distance )
828- else :
829- param = self .param_at (distance / self .length )
819+ if isinstance (self , Wire ):
820+ if frame_method == FrameMethod .CORRECTED :
821+ # BRep_CompCurve parameter
822+ param = self .param_at (distance / self .length )
823+ else :
824+ # A BRep_Curve parameter taken from a Edge based curve
825+ if position_mode == PositionMode .PARAMETER :
826+ curve , param , _ = self ._occt_param_at (
827+ distance * self .length , PositionMode .LENGTH
828+ )
829+ else :
830+ curve , param , _ = self ._occt_param_at (distance , PositionMode .LENGTH )
831+
832+ else : # Edge
833+ if position_mode == PositionMode .PARAMETER :
834+ param = self .param_at (distance )
835+ else :
836+ param = self .param_at (distance / self .length )
837+ curve = self .geom_adaptor ()
830838
831839 law : GeomFill_TrihedronLaw
832840 if frame_method == FrameMethod .FRENET :
@@ -842,18 +850,7 @@ def location_at(
842850 pnt = curve .Value (param )
843851
844852 transformation = gp_Trsf ()
845- if planar is not None :
846- warnings .warn (
847- "The 'planar' parameter is deprecated and will be removed in a future version. "
848- "Use 'x_dir' to control orientation instead." ,
849- DeprecationWarning ,
850- stacklevel = 2 ,
851- )
852- if planar is not None and planar :
853- transformation .SetTransformation (
854- gp_Ax3 (pnt , gp_Dir (0 , 0 , 1 ), gp_Dir (normal .XYZ ())), gp_Ax3 ()
855- )
856- elif x_dir is not None :
853+ if x_dir is not None :
857854 try :
858855
859856 transformation .SetTransformation (
@@ -881,7 +878,6 @@ def locations(
881878 distances : Iterable [float ],
882879 position_mode : PositionMode = PositionMode .PARAMETER ,
883880 frame_method : FrameMethod = FrameMethod .FRENET ,
884- planar : bool | None = None ,
885881 x_dir : VectorLike | None = None ,
886882 ) -> list [Location ]:
887883 """Locations along curve
@@ -894,22 +890,16 @@ def locations(
894890 Defaults to PositionMode.PARAMETER.
895891 frame_method (FrameMethod, optional): moving frame calculation method.
896892 Defaults to FrameMethod.FRENET.
897- planar (bool, optional): planar mode. Defaults to False.
898893 x_dir (VectorLike, optional): override the x_dir to help with plane
899- creation along a 1D shape. Must be perpendicalar to shapes tangent.
894+ creation along a 1D shape. Must be perpendicular to shapes tangent.
900895 Defaults to None.
901896
902- .. deprecated::
903- The `planar` parameter is deprecated and will be removed in a future release.
904- Use `x_dir` to specify orientation instead.
905-
906897 Returns:
907898 list[Location]: A list of Location objects representing local coordinate
908899 systems at the specified distances.
909900 """
910901 return [
911- self .location_at (d , position_mode , frame_method , planar , x_dir )
912- for d in distances
902+ self .location_at (d , position_mode , frame_method , x_dir ) for d in distances
913903 ]
914904
915905 def normal (self ) -> Vector :
@@ -1041,42 +1031,6 @@ def offset_2d(
10411031 offset_edges = offset_wire .edges ()
10421032 return offset_edges [0 ] if len (offset_edges ) == 1 else offset_wire
10431033
1044- def param_at (self , position : float ) -> float :
1045- """
1046- Map a normalized arc-length position to the underlying OCCT parameter.
1047-
1048- The meaning of the returned parameter depends on the type of self:
1049-
1050- - **Edge**: Returns the native OCCT curve parameter corresponding to the
1051- given normalized `position` (0.0 → start, 1.0 → end). For closed/periodic
1052- edges, OCCT may return a value **outside** the edge's nominal parameter
1053- range `[param_min, param_max]` (e.g., by adding/subtracting multiples of
1054- the period). If you require a value folded into the edge's range, apply a
1055- modulo with the parameter span.
1056-
1057- - **Wire**: Returns a *composite* parameter encoding both the edge index
1058- and the position within that edge: the **integer part** is the zero-based
1059- count of fully traversed edges, and the **fractional part** is the
1060- normalized position in `[0.0, 1.0]` along the current edge.
1061-
1062- Args:
1063- position (float): Normalized arc-length position along the shape,
1064- where `0.0` is the start and `1.0` is the end. Values outside
1065- `[0.0, 1.0]` are not validated and yield OCCT-dependent results.
1066-
1067- Returns:
1068- float: OCCT parameter (for edges) **or** composite “edgeIndex + fraction”
1069- parameter (for wires), as described above.
1070-
1071- """
1072-
1073- curve = self .geom_adaptor ()
1074-
1075- length = GCPnts_AbscissaPoint .Length_s (curve )
1076- return GCPnts_AbscissaPoint (
1077- curve , length * position , curve .FirstParameter ()
1078- ).Parameter ()
1079-
10801034 def perpendicular_line (
10811035 self , length : float , u_value : float , plane : Plane = Plane .XY
10821036 ) -> Edge :
@@ -1372,22 +1326,6 @@ def tangent_at(
13721326 """
13731327 return self .derivative_at (position , 1 , position_mode ).normalized ()
13741328
1375- # def vertex(self) -> Vertex | None:
1376- # """Return the Vertex"""
1377- # return Shape.get_single_shape(self, "Vertex")
1378-
1379- # def vertices(self) -> ShapeList[Vertex]:
1380- # """vertices - all the vertices in this Shape"""
1381- # return Shape.get_shape_list(self, "Vertex")
1382-
1383- # def wire(self) -> Wire | None:
1384- # """Return the Wire"""
1385- # return Shape.get_single_shape(self, "Wire")
1386-
1387- # def wires(self) -> ShapeList[Wire]:
1388- # """wires - all the wires in this Shape"""
1389- # return Shape.get_shape_list(self, "Wire")
1390-
13911329
13921330class Edge (Mixin1D [TopoDS_Edge ]):
13931331 """An Edge in build123d is a fundamental element in the topological data structure
@@ -1855,12 +1793,13 @@ def make_constrained_lines(
18551793 direction : VectorLike | None = None ,
18561794 ) -> ShapeList [Edge ]:
18571795 """
1858- Create all planar line(s) on the XY plane tangent to one curve and passing
1859- through a fixed point.
1796+ Create all planar line(s) on the XY plane tangent to one curve with a
1797+ fixed orientation, defined either by an angle measured from a reference
1798+ axis or by a direction vector.
18601799
18611800 Args:
18621801 tangency_one (Edge): edge that line will be tangent to
1863- tangency_two (Axis): axis that angle will be measured against
1802+ tangency_two (Axis): reference axis from which the angle is measured
18641803 angle : float, optional
18651804 Line orientation in degrees (measured CCW from the X-axis).
18661805 direction : VectorLike, optional
@@ -2808,6 +2747,35 @@ def _occt_param_at(
28082747 ).Parameter ()
28092748 return comp_curve , occt_param , self .is_forward
28102749
2750+ def param_at (self , position : float ) -> float :
2751+ """
2752+ Map a normalized arc-length position to the underlying OCCT parameter.
2753+
2754+ Returns the native OCCT curve parameter corresponding to the
2755+ given normalized `position` (0.0 → start, 1.0 → end). For closed/periodic
2756+ edges, OCCT may return a value **outside** the edge's nominal parameter
2757+ range `[param_min, param_max]` (e.g., by adding/subtracting multiples of
2758+ the period). If you require a value folded into the edge's range, apply a
2759+ modulo with the parameter span.
2760+
2761+ Args:
2762+ position (float): Normalized arc-length position along the shape,
2763+ where `0.0` is the start and `1.0` is the end. Values outside
2764+ `[0.0, 1.0]` are not validated and yield OCCT-dependent results.
2765+
2766+ Returns:
2767+ float: OCCT parameter (for edges) **or** composite “edgeIndex + fraction”
2768+ parameter (for wires), as described above.
2769+
2770+ """
2771+
2772+ curve = self .geom_adaptor ()
2773+
2774+ length = GCPnts_AbscissaPoint .Length_s (curve )
2775+ return GCPnts_AbscissaPoint (
2776+ curve , length * position , curve .FirstParameter ()
2777+ ).Parameter ()
2778+
28112779 def param_at_point (self , point : VectorLike ) -> float :
28122780 """
28132781 Return the normalized parameter (∈ [0.0, 1.0]) of the location on this edge
@@ -3870,6 +3838,40 @@ def geom_equal(
38703838 for e1 , e2 in zip (edges1 , edges2 )
38713839 )
38723840
3841+ def param_at (self , position : float ) -> float :
3842+ """
3843+ Return the OCCT comp-curve parameter corresponding to the given wire position.
3844+ This is *not* the edge composite parameter; it is the parameter of the wire’s
3845+ BRepAdaptor_CompCurve.
3846+ """
3847+ curve = self .geom_adaptor ()
3848+
3849+ # Compute the correct target point along the wire
3850+ target_pnt = self .position_at (position )
3851+
3852+ # Project to comp-curve parameter
3853+ extrema = Extrema_ExtPC (target_pnt .to_pnt (), curve )
3854+ if not extrema .IsDone () or extrema .NbExt () == 0 :
3855+ raise RuntimeError ("Failed to find point on curve" )
3856+
3857+ min_dist = float ("inf" )
3858+ closest_pnt = None
3859+ closest_param = None
3860+ for i in range (1 , extrema .NbExt () + 1 ):
3861+ dist = extrema .SquareDistance (i )
3862+ if dist < min_dist :
3863+ min_dist = dist
3864+ closest_pnt = extrema .Point (i ).Value ()
3865+ closest_param = extrema .Point (i ).Parameter ()
3866+
3867+ if (
3868+ closest_pnt is None
3869+ or (Vector (tcast (gp_Pnt , closest_pnt )) - target_pnt ).length > TOLERANCE
3870+ ):
3871+ raise RuntimeError ("Failed to find point on curve" )
3872+
3873+ return tcast (float , closest_param )
3874+
38733875 def param_at_point (self , point : VectorLike ) -> float :
38743876 """
38753877 Return the normalized wire parameter for the point closest to this wire.
@@ -3992,28 +3994,51 @@ def _occt_param_at(
39923994 at the given position, the corresponding OCCT parameter on that edge and
39933995 if edge is_forward.
39943996 """
3995- wire_curve_adaptor = self .geom_adaptor ()
3996-
3997+ # Normalize to absolute distance along the wire
39973998 if position_mode == PositionMode .PARAMETER :
39983999 if not self .is_forward :
3999- position = 1 - position
4000- occt_wire_param = self .param_at ( position )
4000+ position = 1.0 - position
4001+ distance = position * self .length
40014002 else :
40024003 if not self .is_forward :
40034004 position = self .length - position
4004- occt_wire_param = self .param_at (position / self .length )
4005+ distance = position
4006+
4007+ # Build ordered edges and cumulative lengths
4008+ self_edges = self .edges ()
4009+ edge_lengths = [e .length for e in self_edges ]
4010+ cumulative_lengths = []
4011+ total = 0.0
4012+ for edge_length in edge_lengths :
4013+ total += edge_length
4014+ cumulative_lengths .append (total )
4015+
4016+ # Clamp distance
4017+ if distance <= 0.0 :
4018+ edge_idx = 0
4019+ local_dist = 0.0
4020+ elif distance >= total :
4021+ edge_idx = len (self_edges ) - 1
4022+ local_dist = edge_lengths [edge_idx ]
4023+ else :
4024+ edge_idx = bisect_right (cumulative_lengths , distance )
4025+ prev_cum = cumulative_lengths [edge_idx - 1 ] if edge_idx > 0 else 0.0
4026+ local_dist = distance - prev_cum
40054027
4006- topods_edge_at_position = TopoDS_Edge ()
4007- occt_edge_params = wire_curve_adaptor .Edge (
4008- occt_wire_param , topods_edge_at_position
4009- )
4010- edge_curve_adaptor = BRepAdaptor_Curve (topods_edge_at_position )
4028+ target_edge = self_edges [edge_idx ]
40114029
4012- return (
4013- edge_curve_adaptor ,
4014- occt_edge_params [0 ],
4015- topods_edge_at_position .Orientation () == TopAbs_Orientation .TopAbs_FORWARD ,
4030+ # Convert local distance to edge fraction
4031+ local_frac = (
4032+ 0.0 if target_edge .length == 0 else (local_dist / target_edge .length )
40164033 )
4034+ if not target_edge .is_forward :
4035+ local_frac = 1 - local_frac
4036+
4037+ # Use edge param_at to get native OCCT parameter
4038+ occt_edge_param = target_edge .param_at (local_frac )
4039+
4040+ edge_curve_adaptor = target_edge .geom_adaptor ()
4041+ return edge_curve_adaptor , occt_edge_param , target_edge .is_forward
40174042
40184043 def project_to_shape (
40194044 self ,
0 commit comments