From 18aafed8e65e3fbe69a6995b8ca74c14b96a5a60 Mon Sep 17 00:00:00 2001 From: gumyr Date: Tue, 27 Feb 2024 16:18:39 -0500 Subject: [PATCH] Add DoubleTangentArc --- docs/assets/double_tangent_line_example.svg | 13 +++ docs/cheat_sheet.rst | 1 + docs/objects.rst | 8 ++ docs/objects_1d.py | 14 ++- src/build123d/__init__.py | 1 + src/build123d/objects_curve.py | 105 +++++++++++++++++++- tests/test_build_line.py | 41 ++++++++ 7 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 docs/assets/double_tangent_line_example.svg diff --git a/docs/assets/double_tangent_line_example.svg b/docs/assets/double_tangent_line_example.svg new file mode 100644 index 00000000..61895bbf --- /dev/null +++ b/docs/assets/double_tangent_line_example.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/cheat_sheet.rst b/docs/cheat_sheet.rst index dc77adc6..318975f5 100644 --- a/docs/cheat_sheet.rst +++ b/docs/cheat_sheet.rst @@ -17,6 +17,7 @@ Cheat Sheet | :class:`~objects_curve.Bezier` | :class:`~objects_curve.CenterArc` + | :class:`~objects_curve.DoubleTangentArc` | :class:`~objects_curve.EllipticalCenterArc` | :class:`~objects_curve.FilletPolyline` | :class:`~objects_curve.Helix` diff --git a/docs/objects.rst b/docs/objects.rst index 73089285..2c1eff1b 100644 --- a/docs/objects.rst +++ b/docs/objects.rst @@ -90,6 +90,13 @@ The following objects all can be used in BuildLine contexts. Note that +++ Arc defined by center, radius, & angles + .. grid-item-card:: :class:`~objects_curve.DoubleTangentArc` + + .. image:: assets/double_tangent_line_example.svg + + +++ + Arc defined by point/tangent pair & other curve + .. grid-item-card:: :class:`~objects_curve.EllipticalCenterArc` .. image:: assets/elliptical_center_arc_example.svg @@ -189,6 +196,7 @@ Reference .. autoclass:: BaseLineObject .. autoclass:: Bezier .. autoclass:: CenterArc +.. autoclass:: DoubleTangentArc .. autoclass:: EllipticalCenterArc .. autoclass:: FilletPolyline .. autoclass:: Helix diff --git a/docs/objects_1d.py b/docs/objects_1d.py index 8524d9ac..7d029cd0 100644 --- a/docs/objects_1d.py +++ b/docs/objects_1d.py @@ -246,7 +246,19 @@ svg.add_shape(intersecting_line.line) svg.add_shape(dot.moved(Location(Vector((1, 0))))) svg.write("assets/intersecting_line_example.svg") -show(other, intersecting_line) + +with BuildLine() as double_tangent: + l2 = JernArc(start=(0, 20), tangent=(0, 1), radius=5, arc_size=-300) + l3 = DoubleTangentArc((6, 0), tangent=(0, 1), other=l2) +s = 100 / max(*double_tangent.line.bounding_box().size) +svg = ExportSVG(scale=s) +svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) +svg.add_shape(l2, "dashed") +svg.add_shape(l3) +svg.add_shape(dot.scale(5).moved(Pos(6, 0))) +svg.add_shape(Edge.make_line((6, 0), (6, 5)), "dashed") +svg.write("assets/double_tangent_line_example.svg") + # show_object(example_1.line, name="Ex. 1") # show_object(example_2.line, name="Ex. 2") # show_object(example_3.line, name="Ex. 3") diff --git a/src/build123d/__init__.py b/src/build123d/__init__.py index 8136f5af..576e886b 100644 --- a/src/build123d/__init__.py +++ b/src/build123d/__init__.py @@ -73,6 +73,7 @@ "BaseLineObject", "Bezier", "CenterArc", + "DoubleTangentArc", "EllipticalCenterArc", "EllipticalStartArc", "FilletPolyline", diff --git a/src/build123d/objects_curve.py b/src/build123d/objects_curve.py index 9f17960f..ae1e345e 100644 --- a/src/build123d/objects_curve.py +++ b/src/build123d/objects_curve.py @@ -29,13 +29,15 @@ from __future__ import annotations import copy +import warnings from math import copysign, cos, radians, sin, sqrt +from scipy.optimize import minimize from typing import Iterable, Union from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs -from build123d.build_enums import AngularDirection, GeomType, LengthMode, Mode +from build123d.build_enums import AngularDirection, GeomType, Keep, LengthMode, Mode from build123d.build_line import BuildLine -from build123d.geometry import Axis, Plane, Vector, VectorLike +from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE from build123d.topology import Edge, Face, Wire, Curve @@ -149,6 +151,105 @@ def __init__( super().__init__(arc, mode=mode) +class DoubleTangentArc(BaseLineObject): + """Line Object: Double Tangent Arc + + Create an arc defined by a point/tangent pair and another line which the other end + is tangent to. + + Contains a solver. + + Args: + pnt (VectorLike): starting point of tangent arc + tangent (VectorLike): tangent at starting point of tangent arc + other (Union[Curve, Edge, Wire]): reference line + keep (Keep, optional): selector for which arc to keep when two arcs are + possible. The arc generated with TOP or BOTTOM depends on the geometry + and isn't necessarily easy to predict. Defaults to Keep.TOP. + mode (Mode, optional): combination mode. Defaults to Mode.ADD. + + Raises: + RunTimeError: no double tangent arcs found + """ + + _applies_to = [BuildLine._tag] + + def __init__( + self, + pnt: VectorLike, + tangent: VectorLike, + other: Union[Curve, Edge, Wire], + keep: Keep = Keep.TOP, + mode: Mode = Mode.ADD, + ): + context: BuildLine = BuildLine._get_context(self) + validate_inputs(context, self) + + arc_pt = WorkplaneList.localize(pnt) + arc_tangent = WorkplaneList.localize(tangent) + if WorkplaneList._get_context() is not None: + workplane = WorkplaneList._get_context().workplanes[0] + else: + workplane = Edge.make_line(arc_pt, arc_pt + arc_tangent).common_plane( + *other.edges() + ) + if workplane is None: + raise ValueError("DoubleTangentArc only works on a single plane") + workplane = -workplane # Flip to help with TOP/BOTTOM + rotation_axis = Axis((0, 0, 0), workplane.z_dir) + # Protect against massive circles that are effectively straight lines + max_size = 2 * other.bounding_box().add(arc_pt).diagonal + + # Function to be minimized + def func(radius, perpendicular_bisector): + center = arc_pt + perpendicular_bisector * radius + separation = other.distance_to(center) + return abs(separation - radius) + + # Minimize the function using bounds and the tolerance value + arc_centers = [] + for angle in [90, -90]: + perpendicular_bisector = arc_tangent.rotate(rotation_axis, angle) + result = minimize( + func, + x0=0.0, + args=perpendicular_bisector, + method="Nelder-Mead", + bounds=[(0.0, max_size)], + tol=TOLERANCE, + ) + arc_radius = result.x[0] + arc_center = arc_pt + perpendicular_bisector * arc_radius + + # Check for matching tangents + circle = Edge.make_circle( + arc_radius, Plane(arc_center, z_dir=rotation_axis.direction) + ) + dist, p1, p2 = other.distance_to_with_closest_points(circle) + if dist > TOLERANCE: # If they aren't touching + continue + other_axis = Axis(p1, other.tangent_at(p1)) + circle_axis = Axis(p2, circle.tangent_at(p2)) + if other_axis.is_parallel(circle_axis): + arc_centers.append(arc_center) + + if len(arc_centers) == 0: + raise RuntimeError("No double tangent arcs found") + + # If there are multiple solutions, select the desired one + if keep == Keep.TOP: + arc_centers = arc_centers[0:1] + elif keep == Keep.BOTTOM: + arc_centers = arc_centers[-1:] + + with BuildLine() as double: + for center in arc_centers: + _, p1, _ = other.distance_to_with_closest_points(center) + TangentArc(arc_pt, p1, tangent=arc_tangent) + + super().__init__(double.line, mode=mode) + + class EllipticalStartArc(BaseLineObject): """Line Object: Elliptical Start Arc diff --git a/tests/test_build_line.py b/tests/test_build_line.py index b082ac43..bba7cebc 100644 --- a/tests/test_build_line.py +++ b/tests/test_build_line.py @@ -107,6 +107,47 @@ def test_bezier(self): Bezier(*pts, weights=wts) self.assertAlmostEqual(bz.wires()[0].length, 225.86389406824566, 5) + def test_double_tangent_arc(self): + l1 = Line((10, 0), (30, 20)) + l2 = DoubleTangentArc((0, 5), (1, 0), l1) + _, p1, p2 = l1.distance_to_with_closest_points(l2) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertTupleAlmostEquals( + tuple(l1.tangent_at(p1)), tuple(l2.tangent_at(p2)), 5 + ) + + l3 = Line((10, 0), (20, -10)) + l4 = DoubleTangentArc((0, 0), (1, 0), l3) + _, p1, p2 = l3.distance_to_with_closest_points(l4) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertTupleAlmostEquals( + tuple(l3.tangent_at(p1)), tuple(l4.tangent_at(p2)), 5 + ) + + with BuildLine() as test: + l5 = Polyline((20, -10), (10, 0), (20, 10)) + l6 = DoubleTangentArc((0, 0), (1, 0), l5, keep=Keep.BOTTOM) + _, p1, p2 = l5.distance_to_with_closest_points(l6) + self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5) + self.assertTupleAlmostEquals( + tuple(l5.tangent_at(p1)), tuple(l6.tangent_at(p2) * -1), 5 + ) + + l7 = Spline((15, 5), (5, 0), (15, -5), tangents=[(-1, 0), (1, 0)]) + l8 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l7, keep=Keep.BOTH) + self.assertEqual(len(l8.edges()), 2) + + l9 = EllipticalCenterArc((15, 0), 10, 5, start_angle=90, end_angle=270) + l10 = DoubleTangentArc((0, 0, 0), (1, 0, 0), l9, keep=Keep.BOTH) + self.assertEqual(len(l10.edges()), 2) + + with self.assertRaises(ValueError): + DoubleTangentArc((0, 0, 0), (0, 0, 1), l9) + + l11 = Line((10, 0), (20, 0)) + with self.assertRaises(RuntimeError): + DoubleTangentArc((0, 0, 0), (1, 0, 0), l11) + def test_elliptical_start_arc(self): with self.assertRaises(RuntimeError): with BuildLine():