Skip to content

Commit ce0d99a

Browse files
committed
Fixed Issue #1095
1 parent 11a517f commit ce0d99a

File tree

4 files changed

+296
-108
lines changed

4 files changed

+296
-108
lines changed

src/build123d/topology/one_d.py

Lines changed: 132 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353

5454
import copy
5555
import warnings
56+
from bisect import bisect_right
5657
from collections.abc import Iterable, Sequence
5758
from itertools import combinations
5859
from math import atan2, ceil, copysign, cos, floor, inf, isclose, pi, radians
@@ -88,6 +89,7 @@
8889
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
8990
from OCP.BRepProj import BRepProj_Projection
9091
from OCP.BRepTools import BRepTools, BRepTools_WireExplorer
92+
from OCP.Extrema import Extrema_ExtPC
9193
from OCP.GC import (
9294
GC_MakeArcOfCircle,
9395
GC_MakeArcOfEllipse,
@@ -97,6 +99,7 @@
9799
from OCP.GCPnts import (
98100
GCPnts_AbscissaPoint,
99101
GCPnts_QuasiUniformDeflection,
102+
GCPnts_TangentialDeflection,
100103
GCPnts_UniformDeflection,
101104
)
102105
from 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

13921330
class 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

Comments
 (0)