diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 6a717d9..f48c7bb 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -120,6 +120,9 @@ from OCP.GeomAbs import ( GeomAbs_C0, GeomAbs_C1, GeomAbs_C2, + GeomAbs_C3, + GeomAbs_CN, + GeomAbs_C1, GeomAbs_G1, GeomAbs_G2, GeomAbs_JoinType, @@ -545,31 +548,6 @@ class Mixin1D(Shape[TOPODS]): return result - def discretize(self, deflection: float = 0.1, quasi=True) -> list[Vector]: - """Discretize the shape into a list of points""" - if self.wrapped is None: - raise ValueError("Cannot discretize an empty shape") - curve = self.geom_adaptor() - if quasi: - discretizer = GCPnts_QuasiUniformDeflection() - else: - discretizer = GCPnts_UniformDeflection() - discretizer.Initialize( - curve, - deflection, - curve.FirstParameter(), - curve.LastParameter(), - ) - - assert discretizer.IsDone() - - return [ - Vector(v.X(), v.Y(), v.Z()) - for v in ( - curve.Value(discretizer.Parameter(i)) - for i in range(1, discretizer.NbPoints() + 1) - ) - ] def curvature_comb( self, count: int = 100, max_tooth_size: float | None = None ) -> ShapeList[Edge]: @@ -1207,22 +1185,52 @@ class Mixin1D(Shape[TOPODS]): def positions( self, - distances: Iterable[float], + distances: Iterable[float] | None = None, position_mode: PositionMode = PositionMode.PARAMETER, + deflection: float | None = None, ) -> 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. + distances (Iterable[float] | None, optional): distance or parameter values. + Defaults to None. + position_mode (PositionMode, optional): position calculation mode only applies + when using distances. Defaults to PositionMode.PARAMETER. + deflection (float | None, optional): maximum deflection between the curve and + the polygon that results from the computed points. Defaults to None. + Returns: list[Vector]: positions along curve """ - return [self.position_at(d, position_mode) for d in distances] + if deflection is not None: + curve: BRepAdaptor_Curve | BRepAdaptor_CompCurve = self.geom_adaptor() + # GCPnts_UniformDeflection provides the best results but is limited + if curve.Continuity() in (GeomAbs_C2, GeomAbs_C3, GeomAbs_CN): + discretizer: ( + GCPnts_UniformDeflection | GCPnts_QuasiUniformDeflection + ) = GCPnts_UniformDeflection() + else: + discretizer = GCPnts_QuasiUniformDeflection() + + discretizer.Initialize( + curve, + deflection, + curve.FirstParameter(), + curve.LastParameter(), + ) + if not discretizer.IsDone() or discretizer.NbPoints() == 0: + raise RuntimeError("Deflection calculation failed") + return [ + Vector(curve.Value(discretizer.Parameter(i + 1))) + for i in range(discretizer.NbPoints()) + ] + elif distances is not None: + return [self.position_at(d, position_mode) for d in distances] + else: + raise ValueError("Either distances or deflection must be provided") def project( self, face: Face, direction: VectorLike, closest: bool = True diff --git a/tests/test_direct_api/test_mixin1_d.py b/tests/test_direct_api/test_mixin1_d.py index d2bc54e..864711b 100644 --- a/tests/test_direct_api/test_mixin1_d.py +++ b/tests/test_direct_api/test_mixin1_d.py @@ -28,6 +28,7 @@ license: import math import unittest +from unittest.mock import patch from build123d.build_enums import ( CenterOf, @@ -106,13 +107,73 @@ class TestMixin1D(unittest.TestCase): 5, ) - def test_positions(self): + def test_positions_with_distances(self): e = Edge.make_line((0, 0, 0), (1, 1, 1)) distances = [i / 4 for i in range(3)] pts = e.positions(distances) for i, position in enumerate(pts): self.assertAlmostEqual(position, (i / 4, i / 4, i / 4), 5) + def test_positions_deflection_line(self): + """Deflection sampling on a straight line should yield exactly 2 points.""" + e = Edge.make_line((0, 0, 0), (10, 0, 0)) + pts = e.positions(deflection=0.1) + + self.assertEqual(len(pts), 2) + self.assertAlmostEqual(pts[0], (0, 0, 0), 7) + self.assertAlmostEqual(pts[1], (10, 0, 0), 7) + + def test_positions_deflection_circle(self): + """Deflection on a C2 curve (circle) should produce multiple points.""" + radius = 5 + e = Edge.make_circle(radius) + + pts = e.positions(deflection=0.1) + + # Should produce more than just two points + self.assertGreater(len(pts), 2) + + # Endpoints should match curve endpoints + first, last = pts[0], pts[-1] + curve = e.geom_adaptor() + p0 = Vector(curve.Value(curve.FirstParameter())) + p1 = Vector(curve.Value(curve.LastParameter())) + + self.assertAlmostEqual(first, p0, 7) + self.assertAlmostEqual(last, p1, 7) + + def test_positions_deflection_resolution(self): + """Smaller deflection tolerance should produce more points.""" + e = Edge.make_circle(10) + + pts_coarse = e.positions(deflection=0.5) + pts_fine = e.positions(deflection=0.05) + + self.assertGreater(len(pts_fine), len(pts_coarse)) + + def test_positions_deflection_C0_curve(self): + """C0 spline should use QuasiUniformDeflection and still succeed.""" + e = Polyline((0, 0), (1, 2), (2, 0))._to_bspline() # C0 + pts = e.positions(deflection=0.1) + + self.assertGreater(len(pts), 2) + + def test_positions_missing_arguments(self): + e = Edge.make_line((0, 0, 0), (1, 0, 0)) + with self.assertRaises(ValueError): + e.positions() + + def test_positions_deflection_failure(self): + e = Edge.make_circle(1.0) + + with patch("build123d.topology.one_d.GCPnts_UniformDeflection") as MockDefl: + instance = MockDefl.return_value + instance.IsDone.return_value = False + instance.NbPoints.return_value = 0 + + with self.assertRaises(RuntimeError): + e.positions(deflection=0.1) + def test_tangent_at(self): self.assertAlmostEqual( Edge.make_circle(1, start_angle=0, end_angle=90).tangent_at(1.0), @@ -368,11 +429,6 @@ class TestMixin1D(unittest.TestCase): self.assertAlmostEqual(common.z_dir.Y, 0, 5) self.assertAlmostEqual(common.z_dir.Z, 0, 5) - def test_discretize(self): - edge = Edge.make_circle(2, start_angle=0, end_angle=180) - points = edge.discretize(0.1) - self.assertEqual(len(points), 6) - def test_edge_volume(self): edge = Edge.make_line((0, 0), (1, 1)) self.assertAlmostEqual(edge.volume, 0, 5)