Merge pull request #947 from jwagenet/tangent-objects

Add Tangent objects for Point and Arc
This commit is contained in:
Roger Maitland 2025-04-16 09:36:12 -04:00 committed by GitHub
commit b03fa9a7fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 959 additions and 15 deletions

View file

@ -1,13 +1,13 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<svg width="40.09mm" height="101.09mm" viewBox="-0.01125 -25.01125 10.0225 25.2725" version="1.1" xmlns="http://www.w3.org/2000/svg"> <svg width="100.089998mm" height="102.589998mm" viewBox="-0.0045 -10.0045 10.009 10.259" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round"> <g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.0225"> <g fill="none" stroke="rgb(0,0,0)" stroke-width="0.00900000018">
<path d="M 6.0,0.0 A 47.000000000000085,47.000000000000085 180.0 0,0 9.615385,18.076923" /> <path d="M 6.0,0.0 A 11.022002852739636,11.022002852739636 0.0 0,1 1.242,9.069003" />
<circle cx="6.0" cy="0.0" r="0.25" /> <circle cx="6.0" cy="0.0" r="0.25" />
</g> </g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.0225" id="dashed" stroke-dasharray="0.27 0.405"> <g fill="none" stroke="rgb(0,0,0)" stroke-width="0.00900000018" id="dashed" stroke-dasharray="0.108 0.162">
<path d="M 0.0,20.0 A 5.0,5.0 180.0 1,0 2.5,15.669873" /> <path d="M 0.0,10.0 C 2.605146,7.884615 8.294029,4.391384 10.0,10.0" />
<line x1="6.0" y1="0.0" x2="6.0" y2="5.0" /> <line x1="6.0" y1="0.0" x2="6.0" y2="1.0" />
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 708 B

Before After
Before After

View file

@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<svg width="100.09mm" height="100.09mm" viewBox="-0.0045 -10.0045 10.009 10.009" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.008999999999999998">
<path d="M 0.098274,9.997584 A 12.0,12.0 87.18351331597553 0,0 9.497124,4.66264" />
</g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.008999999999999998" id="dashed" stroke-dasharray="0.108 0.162">
<circle cx="7.0" cy="3.0" r="3.0" />
<path d="M -0.0,6.0 A 2.0,2.0 0.0 0,1 0.0,10.0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 639 B

View file

@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<svg width="100.09mm" height="100.09mm" viewBox="-0.0045 -10.0045 10.009 10.009" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009">
<line x1="8.448109" y1="5.627352" x2="0.965406" y2="9.751568" />
</g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009" id="dashed" stroke-dasharray="0.108 0.162">
<circle cx="7.0" cy="3.0" r="3.0" />
<path d="M -0.0,6.0 A 2.0,2.0 0.0 0,1 0.0,10.0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View file

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<svg width="102.59mm" height="100.09mm" viewBox="-0.0045 -10.0045 10.259 10.009" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009000000000000001">
<path d="M 10.0,3.0 A 10.575383789062535,10.575383789062535 -108.43494882292202 0,0 4.283756,7.578649" />
<circle cx="10.0" cy="3.0" r="0.25" />
</g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009000000000000001" id="dashed" stroke-dasharray="0.108 0.162">
<path d="M -0.0,0.0 A 5.0,5.0 0.0 0,1 0.0,10.0" />
<line x1="10.0" y1="3.0" x2="9.051317" y2="3.316228" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

View file

@ -0,0 +1,12 @@
<?xml version='1.0' encoding='utf-8'?>
<svg width="102.59mm" height="100.09mm" viewBox="-0.0045 -10.0045 10.259 10.009" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009000000000000001">
<line x1="10.0" y1="3.0" x2="3.25848" y2="8.792401" />
<circle cx="10.0" cy="3.0" r="0.25" />
</g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.009000000000000001" id="dashed" stroke-dasharray="0.108 0.162">
<path d="M -0.0,0.0 A 5.0,5.0 0.0 0,1 0.0,10.0" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 612 B

View file

@ -188,6 +188,33 @@ The following objects all can be used in BuildLine contexts. Note that
+++ +++
Curve define by three points Curve define by three points
.. grid-item-card:: :class:`~objects_curve.ArcArcTangentLine`
.. image:: assets/example_arc_arc_tangent_line.svg
+++
Line tangent defined by two arcs
.. grid-item-card:: :class:`~objects_curve.ArcArcTangentArc`
.. image:: assets/example_arc_arc_tangent_arc.svg
+++
Arc tangent defined by two arcs
.. grid-item-card:: :class:`~objects_curve.PointArcTangentLine`
.. image:: assets/example_point_arc_tangent_line.svg
+++
Line tangent defined by a point and arc
.. grid-item-card:: :class:`~objects_curve.PointArcTangentArc`
.. image:: assets/example_point_arc_tangent_arc.svg
+++
Arc tangent defined by a point, direction, and arc
Reference Reference
^^^^^^^^^ ^^^^^^^^^
@ -210,6 +237,10 @@ Reference
.. autoclass:: Spline .. autoclass:: Spline
.. autoclass:: TangentArc .. autoclass:: TangentArc
.. autoclass:: ThreePointArc .. autoclass:: ThreePointArc
.. autoclass:: ArcArcTangentLine
.. autoclass:: ArcArcTangentArc
.. autoclass:: PointArcTangentLine
.. autoclass:: PointArcTangentArc
2D Objects 2D Objects
---------- ----------

View file

@ -176,7 +176,6 @@ svg.write("assets/polyline_example.svg")
with BuildLine(Plane.YZ) as filletpolyline: with BuildLine(Plane.YZ) as filletpolyline:
FilletPolyline((0, 0, 0), (0, 10, 2), (0, 10, 10), (5, 20, 10), radius=2) FilletPolyline((0, 0, 0), (0, 10, 2), (0, 10, 10), (5, 20, 10), radius=2)
show(filletpolyline)
scene = Compound(filletpolyline.line) + Compound.make_triad(2) scene = Compound(filletpolyline.line) + Compound.make_triad(2)
visible, _hidden = scene.project_to_viewport((0, 0, 1), (0, 1, 0)) visible, _hidden = scene.project_to_viewport((0, 0, 1), (0, 1, 0))
s = 100 / max(*Compound(children=visible).bounding_box().size) s = 100 / max(*Compound(children=visible).bounding_box().size)
@ -248,17 +247,71 @@ svg.add_shape(dot.moved(Location(Vector((1, 0)))))
svg.write("assets/intersecting_line_example.svg") svg.write("assets/intersecting_line_example.svg")
with BuildLine() as double_tangent: with BuildLine() as double_tangent:
l2 = JernArc(start=(0, 20), tangent=(0, 1), radius=5, arc_size=-300) p1 = (6, 0)
l3 = DoubleTangentArc((6, 0), tangent=(0, 1), other=l2) d1 = (0, 1)
l2 = Spline((0, 10), (3, 8), (7, 7), (10, 10))
show_object([p1, l2])
l3 = DoubleTangentArc(p1, tangent=d1, other=l2)
s = 100 / max(*double_tangent.line.bounding_box().size) s = 100 / max(*double_tangent.line.bounding_box().size)
svg = ExportSVG(scale=s) svg = ExportSVG(scale=s)
svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE) svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE)
svg.add_shape(l2, "dashed") svg.add_shape(l2, "dashed")
svg.add_shape(l3) svg.add_shape(l3)
svg.add_shape(dot.scale(5).moved(Pos(6, 0))) svg.add_shape(dot.scale(5).moved(Pos(p1)))
svg.add_shape(Edge.make_line((6, 0), (6, 5)), "dashed") svg.add_shape(PolarLine(p1, 1, direction=d1), "dashed")
svg.write("assets/double_tangent_line_example.svg") svg.write("assets/double_tangent_line_example.svg")
with BuildLine() as point_arc_tangent_line:
p1 = (10, 3)
l1 = CenterArc((0, 5), 5, -90, 180)
l2 = PointArcTangentLine(p1, l1, Side.RIGHT)
s = 100 / max(*point_arc_tangent_line.line.bounding_box().size)
svg = ExportSVG(scale=s)
svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE)
svg.add_shape(l1, "dashed")
svg.add_shape(l2)
svg.add_shape(dot.scale(5).moved(Pos(p1)))
svg.write("assets/example_point_arc_tangent_line.svg")
with BuildLine() as point_arc_tangent_arc:
p1 = (10, 3)
d1 = (-3, 1)
l1 = CenterArc((0, 5), 5, -90, 180)
l2 = PointArcTangentArc(p1, d1, l1, Side.RIGHT)
s = 100 / max(*point_arc_tangent_arc.line.bounding_box().size)
svg = ExportSVG(scale=s)
svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE)
svg.add_shape(l1, "dashed")
svg.add_shape(l2)
svg.add_shape(dot.scale(5).moved(Pos(p1)))
svg.add_shape(PolarLine(p1, 1, direction=d1), "dashed")
svg.write("assets/example_point_arc_tangent_arc.svg")
with BuildLine() as arc_arc_tangent_line:
l1 = CenterArc((7, 3), 3, 0, 360)
l2 = CenterArc((0, 8), 2, -90, 180)
l3 = ArcArcTangentLine(l1, l2, Side.RIGHT, Keep.OUTSIDE)
s = 100 / max(*arc_arc_tangent_line.line.bounding_box().size)
svg = ExportSVG(scale=s)
svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE)
svg.add_shape(l1, "dashed")
svg.add_shape(l2, "dashed")
svg.add_shape(l3)
svg.write("assets/example_arc_arc_tangent_line.svg")
with BuildLine() as arc_arc_tangent_arc:
l1 = CenterArc((7, 3), 3, 0, 360)
l2 = CenterArc((0, 8), 2, -90, 180)
radius = 12
l3 = ArcArcTangentArc(l1, l2, radius, Side.LEFT, Keep.OUTSIDE)
s = 100 / max(*arc_arc_tangent_arc.line.bounding_box().size)
svg = ExportSVG(scale=s)
svg.add_layer("dashed", line_type=LineType.ISO_DASH_SPACE)
svg.add_shape(l1, "dashed")
svg.add_shape(l2, "dashed")
svg.add_shape(l3)
svg.write("assets/example_arc_arc_tangent_arc.svg")
# show_object(example_1.line, name="Ex. 1") # show_object(example_1.line, name="Ex. 1")
# show_object(example_2.line, name="Ex. 2") # show_object(example_2.line, name="Ex. 2")
# show_object(example_3.line, name="Ex. 3") # show_object(example_3.line, name="Ex. 3")

View file

@ -45,6 +45,7 @@ dependencies = [
"lib3mf >= 2.4.1", "lib3mf >= 2.4.1",
"ocpsvg >= 0.5, < 0.6", "ocpsvg >= 0.5, < 0.6",
"trianglesolver", "trianglesolver",
"sympy",
] ]
[project.urls] [project.urls]
@ -68,7 +69,6 @@ development = [
"pytest-benchmark", "pytest-benchmark",
"pytest-cov", "pytest-cov",
"pytest-xdist", "pytest-xdist",
"sympy",
"wheel", "wheel",
] ]

View file

@ -93,6 +93,10 @@ __all__ = [
"TangentArc", "TangentArc",
"JernArc", "JernArc",
"ThreePointArc", "ThreePointArc",
"PointArcTangentLine",
"ArcArcTangentLine",
"PointArcTangentArc",
"ArcArcTangentArc",
# 2D Sketch Objects # 2D Sketch Objects
"ArrowHead", "ArrowHead",
"Arrow", "Arrow",

View file

@ -29,16 +29,24 @@ license:
from __future__ import annotations from __future__ import annotations
import copy as copy_module import copy as copy_module
from collections.abc import Iterable
from math import copysign, cos, radians, sin, sqrt from math import copysign, cos, radians, sin, sqrt
from scipy.optimize import minimize from scipy.optimize import minimize
import sympy # type: ignore
from collections.abc import Iterable
from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs from build123d.build_common import WorkplaneList, flatten_sequence, validate_inputs
from build123d.build_enums import AngularDirection, GeomType, Keep, LengthMode, Mode from build123d.build_enums import (
AngularDirection,
GeomType,
LengthMode,
Keep,
Mode,
Side,
)
from build123d.build_line import BuildLine from build123d.build_line import BuildLine
from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE from build123d.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE
from build123d.topology import Edge, Face, Wire, Curve from build123d.topology import Edge, Face, Wire, Curve
from build123d.topology.shape_core import ShapeList
def _add_curve_to_context(curve, mode: Mode): def _add_curve_to_context(curve, mode: Mode):
@ -1048,3 +1056,426 @@ class ThreePointArc(BaseEdgeObject):
arc = Edge.make_three_point_arc(*points_localized) arc = Edge.make_three_point_arc(*points_localized)
super().__init__(arc, mode=mode) super().__init__(arc, mode=mode)
class PointArcTangentLine(BaseEdgeObject):
"""Line Object: Point Arc Tangent Line
Create a straight, tangent line from a point to a circular arc.
Args:
point (VectorLike): intersection point for tangent
arc (Curve | Edge | Wire): circular arc to tangent, must be GeomType.CIRCLE
side (Side, optional): side of arcs to place tangent arc center, LEFT or RIGHT.
Defaults to Side.LEFT
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
_applies_to = [BuildLine._tag]
def __init__(
self,
point: VectorLike,
arc: Curve | Edge | Wire,
side: Side = Side.LEFT,
mode: Mode = Mode.ADD,
):
side_sign = {
Side.LEFT: -1,
Side.RIGHT: 1,
}
context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self)
if arc.geom_type != GeomType.CIRCLE:
raise ValueError("Arc must have GeomType.CIRCLE.")
tangent_point = WorkplaneList.localize(point)
if context is None:
# Making the plane validates points and arc are coplanar
coplane = Edge.make_line(tangent_point, arc.arc_center).common_plane(
arc
)
if coplane is None:
raise ValueError("PointArcTangentLine only works on a single plane.")
workplane = Plane(coplane.origin, z_dir=arc.normal())
else:
workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0])
arc_center = arc.arc_center
radius = arc.radius
midline = tangent_point - arc_center
if midline.length <= radius:
raise ValueError("Cannot find tangent for point on or inside arc.")
# Find angle phi between midline and x
# and angle theta between midplane length and radius
# add the resulting angles with a sign on theta to pick a direction
# This angle is the tangent location around the circle from x
phi = midline.get_signed_angle(workplane.x_dir)
other_leg = sqrt(midline.length**2 - radius**2)
theta = WorkplaneList.localize((radius, other_leg)).get_signed_angle(
workplane.x_dir
)
angle = side_sign[side] * theta + phi
intersect = (
WorkplaneList.localize(
(radius * cos(radians(angle)), radius * sin(radians(angle)))
)
+ arc_center
)
tangent = Edge.make_line(tangent_point, intersect)
super().__init__(tangent, mode)
class PointArcTangentArc(BaseEdgeObject):
"""Line Object: Point Arc Tangent Arc
Create an arc defined by a point/tangent pair and another line which the other end
is tangent to.
Args:
point (VectorLike): starting point of tangent arc
direction (VectorLike): direction at starting point of tangent arc
arc (Union[Curve, Edge, Wire]): ending arc, must be GeomType.CIRCLE
side (Side, optional): select which arc to keep Defaults to Side.LEFT
mode (Mode, optional): combination mode. Defaults to Mode.ADD
Raises:
ValueError: Arc must have GeomType.CIRCLE
ValueError: Point is already tangent to arc
RuntimeError: No tangent arc found
"""
_applies_to = [BuildLine._tag]
def __init__(
self,
point: VectorLike,
direction: VectorLike,
arc: Curve | Edge | Wire,
side: Side = Side.LEFT,
mode: Mode = Mode.ADD,
):
context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self)
if arc.geom_type != GeomType.CIRCLE:
raise ValueError("Arc must have GeomType.CIRCLE")
arc_point = WorkplaneList.localize(point)
wp_tangent = WorkplaneList.localize(direction).normalized()
if context is None:
# Making the plane validates point, tangent, and arc are coplanar
coplane = Edge.make_line(arc_point, arc_point + wp_tangent).common_plane(
arc
)
if coplane is None:
raise ValueError("PointArcTangentArc only works on a single plane.")
workplane = Plane(coplane.origin, z_dir=arc.normal())
else:
workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0])
arc_tangent = (
Vector(direction)
.transform(workplane.reverse_transform, is_direction=True)
.normalized()
)
midline = arc_point - arc.arc_center
if midline.length == arc.radius:
raise ValueError("Cannot find tangent for point on arc.")
if midline.length <= arc.radius:
raise NotImplementedError("Point inside arc not yet implemented.")
# Determine where arc_point is located relative to arc
# ref forms a bisecting line parallel to arc tangent with same distance from arc
# center as arc point in direction of arc tangent
tangent_perp = arc_tangent.cross(workplane.z_dir)
ref_scale = (arc.arc_center - arc_point).dot(-arc_tangent)
ref = ref_scale * arc_tangent + arc.arc_center
ref_to_point = (arc_point - ref).dot(tangent_perp)
keep_sign = -1 if side == Side.LEFT else 1
# Tangent radius to infinity (and beyond)
if keep_sign * ref_to_point == arc.radius:
raise ValueError("Point is already tangent to arc, use tangent line.")
# Use magnitude and sign of ref to arc point along with keep to determine
# which "side" angle the arc center will be on
# - the arc center is the same side if the point is further from ref than arc radius
# - minimize type determines near or far side arc to minimize to
side_sign = 1 if ref_to_point < 0 else -1
if abs(ref_to_point) < arc.radius:
# point/tangent pointing inside arc, both arcs near
arc_type = 1
angle = keep_sign * -90
if ref_scale > 1:
angle = -angle
else:
# point/tangent pointing outside arc, one near arc one far
angle = side_sign * -90
if side == side.LEFT:
arc_type = -side_sign
else:
arc_type = side_sign
# Protect against massive circles that are effectively straight lines
max_size = 1000 * arc.bounding_box().add(arc_point).diagonal
# Function to be minimized - note radius is a numpy array
def func(radius, perpendicular_bisector, minimize_type):
center = arc_point + perpendicular_bisector * radius[0]
separation = (arc.arc_center - center).length - arc.radius
if minimize_type == 1:
# near side arc
target = abs(separation - radius)
elif minimize_type == -1:
# far side arc
target = abs(separation - radius + arc.radius * 2)
return target
# Find arc center by minimizing func result
rotation_axis = Axis(workplane.origin, workplane.z_dir)
perpendicular_bisector = arc_tangent.rotate(rotation_axis, angle)
result = minimize(
func,
x0=0,
args=(perpendicular_bisector, arc_type),
method="Nelder-Mead",
bounds=[(0.0, max_size)],
tol=TOLERANCE,
)
tangent_radius = result.x[0]
tangent_center = arc_point + perpendicular_bisector * tangent_radius
# Check if minimizer hit max size
if tangent_radius == max_size:
raise RuntimeError("Arc radius very large. Can tangent line be used?")
# dir needs to be flipped for far arc
tangent_normal = (arc.arc_center - tangent_center).normalized()
tangent_dir = arc_type * tangent_normal.cross(workplane.z_dir)
tangent_point = tangent_radius * tangent_normal + tangent_center
# Sanity Checks
# Confirm tangent point is on arc
if abs(arc.radius - (tangent_point - arc.arc_center).length) > TOLERANCE:
raise RuntimeError("No tangent arc found, no tangent point found.")
# Confirm new tangent point is colinear with point tangent on arc
arc_dir = arc.tangent_at(tangent_point)
if tangent_dir.cross(arc_dir).length > TOLERANCE:
raise RuntimeError("No tangent arc found, found tangent out of tolerance.")
arc = TangentArc(arc_point, tangent_point, tangent=arc_tangent)
super().__init__(arc, mode=mode)
class ArcArcTangentLine(BaseEdgeObject):
"""Line Object: Arc Arc Tangent Line
Create a straight line tangent to two arcs.
Args:
start_arc (Curve | Edge | Wire): starting arc, must be GeomType.CIRCLE
end_arc (Curve | Edge | Wire): ending arc, must be GeomType.CIRCLE
side (Side): side of arcs to place tangent arc center, LEFT or RIGHT.
Defaults to Side.LEFT
keep (Keep): which tangent arc to keep, INSIDE or OUTSIDE.
Defaults to Keep.INSIDE
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
_applies_to = [BuildLine._tag]
def __init__(
self,
start_arc: Curve | Edge | Wire,
end_arc: Curve | Edge | Wire,
side: Side = Side.LEFT,
keep: Keep = Keep.INSIDE,
mode: Mode = Mode.ADD,
):
context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self)
if start_arc.geom_type != GeomType.CIRCLE:
raise ValueError("Start arc must have GeomType.CIRCLE.")
if end_arc.geom_type != GeomType.CIRCLE:
raise ValueError("End arc must have GeomType.CIRCLE.")
if context is None:
# Making the plane validates start arc and end arc are coplanar
coplane = start_arc.common_plane(end_arc)
if coplane is None:
raise ValueError("ArcArcTangentLine only works on a single plane.")
workplane = Plane(coplane.origin, z_dir=start_arc.normal())
else:
workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0])
side_sign = 1 if side == Side.LEFT else -1
arcs = [start_arc, end_arc]
points = [arc.arc_center for arc in arcs]
radii = [arc.radius for arc in arcs]
midline = points[1] - points[0]
if midline.length <= abs(radii[1] - radii[0]):
raise ValueError("Cannot find tangent when one arc contains the other.")
if keep == Keep.INSIDE:
if midline.length < sum(radii):
raise ValueError("Cannot find INSIDE tangent for overlapping arcs.")
if midline.length == sum(radii):
raise ValueError("Cannot find INSIDE tangent for tangent arcs.")
# Method:
# https://en.wikipedia.org/wiki/Tangent_lines_to_circles#Tangent_lines_to_two_circles
# - angle to point on circle of tangent incidence is theta + phi
# - phi is angle between x axis and midline
# - OUTSIDE theta is angle formed by triangle legs (midline.length) and (r0 - r1)
# - INSIDE theta is angle formed by triangle legs (midline.length) and (r0 + r1)
# - INSIDE theta for arc1 is 180 from theta for arc0
phi = midline.get_signed_angle(workplane.x_dir)
radius = radii[0] + radii[1] if keep == Keep.INSIDE else radii[0] - radii[1]
other_leg = sqrt(midline.length**2 - radius**2)
theta = WorkplaneList.localize((radius, other_leg)).get_signed_angle(
workplane.x_dir
)
angle = side_sign * theta + phi
intersect = []
for i in range(len(arcs)):
angle = i * 180 + angle if keep == Keep.INSIDE else angle
intersect.append(
WorkplaneList.localize(
(radii[i] * cos(radians(angle)), radii[i] * sin(radians(angle)))
)
+ points[i]
)
tangent = Edge.make_line(intersect[0], intersect[1])
super().__init__(tangent, mode)
class ArcArcTangentArc(BaseEdgeObject):
"""Line Object: Arc Arc Tangent Arc
Create an arc tangent to two arcs and a radius.
Args:
start_arc (Curve | Edge | Wire): starting arc, must be GeomType.CIRCLE
end_arc (Curve | Edge | Wire): ending arc, must be GeomType.CIRCLE
radius (float): radius of tangent arc
side (Side): side of arcs to place tangent arc center, LEFT or RIGHT.
Defaults to Side.LEFT
keep (Keep): which tangent arc to keep, INSIDE or OUTSIDE.
Defaults to Keep.INSIDE
mode (Mode, optional): combination mode. Defaults to Mode.ADD
"""
_applies_to = [BuildLine._tag]
def __init__(
self,
start_arc: Curve | Edge | Wire,
end_arc: Curve | Edge | Wire,
radius: float,
side: Side = Side.LEFT,
keep: Keep = Keep.INSIDE,
mode: Mode = Mode.ADD,
):
context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self)
if start_arc.geom_type != GeomType.CIRCLE:
raise ValueError("Start arc must have GeomType.CIRCLE.")
if end_arc.geom_type != GeomType.CIRCLE:
raise ValueError("End arc must have GeomType.CIRCLE.")
if context is None:
# Making the plane validates start arc and end arc are coplanar
coplane = start_arc.common_plane(end_arc)
if coplane is None:
raise ValueError("ArcArcTangentArc only works on a single plane.")
workplane = Plane(coplane.origin, z_dir=start_arc.normal())
else:
workplane = copy_module.copy(WorkplaneList._get_context().workplanes[0])
side_sign = 1 if side == Side.LEFT else -1
keep_sign = 1 if keep == Keep.INSIDE else -1
arcs = [start_arc, end_arc]
points = [arc.arc_center for arc in arcs]
radii = [arc.radius for arc in arcs]
# make a normal vector for sorting intersections
midline = points[1] - points[0]
normal = side_sign * midline.cross(workplane.z_dir)
if midline.length == 0:
raise ValueError("Cannot find tangent for concentric arcs.")
if midline.length <= abs(radii[1] - radii[0]):
raise NotImplementedError("Arc inside arc not yet implemented.")
# The range midline.length / 2 < tangent radius < math.inf should be valid
# Sometimes fails if min_radius == radius, so using >=
min_radius = (midline.length - keep_sign * (radii[0] + radii[1])) / 2
if min_radius >= radius:
raise ValueError(
f"The arc radius is too small. Should be greater than {min_radius}."
)
# Method:
# https://www.youtube.com/watch?v=-STj2SSv6TU
# - the centerpoint of the inner arc is found by the intersection of the
# arcs made by adding the inner radius to the point radii
# - the centerpoint of the outer arc is found by the intersection of the
# arcs made by subtracting the outer radius from the point radii
# - then it's a matter of finding the points where the connecting lines
# intersect the point circles
local = [workplane.to_local_coords(p) for p in points]
ref_circles = [
sympy.Circle(
sympy.Point(local[i].X, local[i].Y), keep_sign * radii[i] + radius
)
for i in range(len(arcs))
]
ref_intersections = ShapeList(
[
workplane.from_local_coords(
Vector(float(sympy.N(p.x)), float(sympy.N(p.y)))
)
for p in sympy.intersection(*ref_circles)
]
)
arc_center = ref_intersections.sort_by(Axis(points[0], normal))[0]
intersect = [
points[i]
+ keep_sign * radii[i] * (Vector(arc_center) - points[i]).normalized()
for i in range(len(arcs))
]
if side == Side.LEFT:
intersect.reverse()
arc = RadiusArc(intersect[0], intersect[1], radius=radius)
super().__init__(arc, mode)

View file

@ -373,6 +373,382 @@ class BuildLineTests(unittest.TestCase):
self.assertEqual(len(test.edges()), 4) self.assertEqual(len(test.edges()), 4)
self.assertAlmostEqual(test.wires()[0].length, 4) self.assertAlmostEqual(test.wires()[0].length, 4)
def test_point_arc_tangent_line(self):
"""Test tangent line between point and arc
Considerations:
- Should produce a GeomType.LINE located on and tangent to arc
- Should start on point
- Lines should always have equal length as long as point is same distance
- LEFT lines should always end on end arc left of midline (angle > 0)
- Arc should be GeomType.CIRCLE
- Point and arc must be coplanar
- Cannot make tangent from point inside arc
"""
# Test line properties in algebra mode
point = (0, 0)
separation = 10
end_point = (0, separation)
end_r = 5
end_arc = CenterArc(end_point, end_r, 0, 360)
lines = []
for side in [Side.LEFT, Side.RIGHT]:
l1 = PointArcTangentLine(point, end_arc, side=side)
self.assertEqual(l1.geom_type, GeomType.LINE)
self.assertTupleAlmostEquals(tuple(point), tuple(l1 @ 0), 5)
_, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
lines.append(l1)
self.assertAlmostEqual(lines[0].length, lines[1].length, 5)
# Test in off-axis builder mode at multiple angles and compare to prev result
workplane = Plane.XY.rotated((45, 45, 45))
with BuildLine(workplane):
end_center = workplane.from_local_coords(end_point)
point_arc = CenterArc(end_center, separation, 0, 360)
end_arc = CenterArc(end_center, end_r, 0, 360)
points = [1, 2, 3, 5, 7, 11, 13]
for point in points:
start_point = point_arc @ (point / 16)
mid_vector = end_center - start_point
mid_perp = mid_vector.cross(workplane.z_dir)
for side in [Side.LEFT, Side.RIGHT]:
l2 = PointArcTangentLine(start_point, end_arc, side=side)
self.assertAlmostEqual(lines[0].length, l2.length, 5)
# Check side
coincident_dir = mid_perp.dot(l2 @ 1 - end_center)
if side == Side.LEFT:
self.assertLess(coincident_dir, 0)
elif side == Side.RIGHT:
self.assertGreater(coincident_dir, 0)
# Error Handling
bad_type = Line((0, 0), (0, 10))
with self.assertRaises(ValueError):
PointArcTangentLine(start_point, bad_type)
with self.assertRaises(ValueError):
PointArcTangentLine(start_point, CenterArc((0, 1, 1), end_r, 0, 360))
with self.assertRaises(ValueError):
PointArcTangentLine(start_point, CenterArc((0, 1), end_r, 0, 360))
def test_point_arc_tangent_arc(self):
"""Test tangent arc between point and arc
Considerations:
- Should produce a GeomType.CIRCLE located on and tangent to arc
- Should start on point tangent to direction
- LEFT lines should always end on end arc left of midline (angle > 0)
- Tangent should be GeomType.CIRCLE
- Point and arc must be coplanar
- Cannot make tangent arc from point/direction already tangent with arc
- (Due to minimizer limit) Cannot make tangent with very large radius
"""
# Test line properties in algebra mode
start_point = (0, 0)
direction = (0, 1)
separation = 10
end_point = (0, separation)
end_r = 5
end_arc = CenterArc(end_point, end_r, 0, 360)
lines = []
for side in [Side.LEFT, Side.RIGHT]:
l1 = PointArcTangentArc(start_point, direction, end_arc, side=side)
self.assertEqual(l1.geom_type, GeomType.CIRCLE)
self.assertTupleAlmostEquals(tuple(start_point), tuple(l1 @ 0), 5)
self.assertAlmostEqual(Vector(direction).cross(l1 % 0).length, 0, 5)
_, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
lines.append(l1)
# Test in off-axis builder mode at multiple angles and compare to prev result
workplane = Plane.XY.rotated((45, 45, 45))
with BuildLine(workplane):
end_center = workplane.from_local_coords(end_point)
end_arc = CenterArc(end_center, end_r, 0, 360)
# Assortment of points in different regimes
flip = separation * 2
value = flip - end_r
points = [
start_point,
(end_r - 0.1, 0),
(-end_r - 0.1, 0),
(end_r + 0.1, flip),
(-end_r + 0.1, flip),
(0, flip),
(flip, flip),
(-flip, -flip),
(value, -value),
(-value, value),
]
for point in points:
mid_vector = end_center - point
mid_perp = mid_vector.cross(workplane.z_dir)
centers = {}
for side in [Side.LEFT, Side.RIGHT]:
l2 = PointArcTangentArc(point, direction, end_arc, side=side)
centers[side] = l2.center()
if point == start_point:
self.assertAlmostEqual(lines[0].length, l2.length, 5)
# Rudimentary side check. Somewhat surprised this works
center_dif = centers[Side.RIGHT] - centers[Side.LEFT]
self.assertGreater(mid_perp.dot(center_dif), 0)
# Error Handling
end_arc = CenterArc(end_point, end_r, 0, 360)
# GeomType
bad_type = Line((0, 0), (0, 10))
with self.assertRaises(ValueError):
PointArcTangentArc(start_point, direction, bad_type)
# Coplanar
with self.assertRaises(ValueError):
arc = CenterArc((0, 1, 1), end_r, 0, 360)
PointArcTangentArc(start_point, direction, arc)
# Positional
with self.assertRaises(ValueError):
PointArcTangentArc((end_r, 0), direction, end_arc, side=Side.RIGHT)
with self.assertRaises(RuntimeError):
PointArcTangentArc(
(end_r - 0.00001, 0), direction, end_arc, side=Side.RIGHT
)
def test_arc_arc_tangent_line(self):
"""Test tangent line between arcs
Considerations:
- Should produce a GeomType.LINE located on and tangent to arcs
- INSIDE arcs cross midline of arc centers
- INSIDE lines should always have equal length as long as arcs are same distance
- OUTSIDE lines should always have equal length as long as arcs are same distance
- LEFT lines should always start on start arc left of midline (angle > 0)
- Tangent should be GeomType.CIRCLE
- Arcs must be coplanar
- Cannot make tangent for concentric arcs
- Cannot make INSIDE tangent from overlapping or tangent arcs
"""
# Test line properties in algebra mode
start_r = 2
end_r = 5
separation = 10
start_point = (0, 0)
end_point = (0, separation)
start_arc = CenterArc(start_point, start_r, 0, 360)
end_arc = CenterArc(end_point, end_r, 0, 360)
lines = []
for keep in [Keep.INSIDE, Keep.OUTSIDE]:
for side in [Side.LEFT, Side.RIGHT]:
l1 = ArcArcTangentLine(start_arc, end_arc, side=side, keep=keep)
self.assertEqual(l1.geom_type, GeomType.LINE)
# Check coincidence, tangency with each arc
_, p1, p2 = start_arc.distance_to_with_closest_points(l1 @ 0)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
_, p1, p2 = end_arc.distance_to_with_closest_points(l1 @ 1)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
lines.append(l1)
self.assertAlmostEqual(lines[-2].length, lines[-1].length, 5)
# Test in off-axis builder mode at multiple angles and compare to prev result
workplane = Plane.XY.rotated((45, 45, 45))
with BuildLine(workplane):
end_center = workplane.from_local_coords(end_point)
point_arc = CenterArc(end_center, separation, 0, 360)
end_arc = CenterArc(end_center, end_r, 0, 360)
points = [1, 2, 3, 5, 7, 11, 13]
for point in points:
start_center = point_arc @ (point / 16)
start_arc = CenterArc(start_center, start_r, 0, 360)
midline = Line(start_center, end_center)
mid_vector = end_center - start_center
mid_perp = mid_vector.cross(workplane.z_dir)
for keep in [Keep.INSIDE, Keep.OUTSIDE]:
for side in [Side.LEFT, Side.RIGHT]:
l2 = ArcArcTangentLine(start_arc, end_arc, side=side, keep=keep)
# Check length and cross/does not cross midline
d1 = midline.distance_to(l2)
if keep == Keep.INSIDE:
self.assertAlmostEqual(d1, 0, 5)
self.assertAlmostEqual(lines[0].length, l2.length, 5)
elif keep == Keep.OUTSIDE:
self.assertNotAlmostEqual(d1, 0, 5)
self.assertAlmostEqual(lines[2].length, l2.length, 5)
# Check side of midline
_, _, p2 = start_arc.distance_to_with_closest_points(l2)
coincident_dir = mid_perp.dot(p2 - start_center)
if side == Side.LEFT:
self.assertLess(coincident_dir, 0)
elif side == Side.RIGHT:
self.assertGreater(coincident_dir, 0)
## Error Handling
start_arc = CenterArc(start_point, start_r, 0, 360)
end_arc = CenterArc(end_point, end_r, 0, 360)
# GeomType
bad_type = Line((0, 0), (0, 10))
with self.assertRaises(ValueError):
ArcArcTangentLine(start_arc, bad_type)
with self.assertRaises(ValueError):
ArcArcTangentLine(bad_type, end_arc)
# Coplanar
with self.assertRaises(ValueError):
ArcArcTangentLine(CenterArc((0, 0, 1), 5, 0, 360), end_arc)
# Position conditions
with self.assertRaises(ValueError):
ArcArcTangentLine(CenterArc(end_point, start_r, 0, 360), end_arc)
with self.assertRaises(ValueError):
arc = CenterArc(start_point, separation - end_r, 0, 360)
ArcArcTangentLine(arc, end_arc, keep=Keep.INSIDE)
with self.assertRaises(ValueError):
arc = CenterArc(start_point, separation - end_r + 1, 0, 360)
ArcArcTangentLine(arc, end_arc, keep=Keep.INSIDE)
def test_arc_arc_tangent_arc(self):
"""Test tangent arc between arcs
Considerations:
- Should produce a GeomType.CIRCLE located on and tangent to arcs
- Tangent arcs that share a side have arc centers on the same side of the midline
- LEFT arcs have centers to right of midline
- INSIDE lines should always have equal length as long as arcs are same distance
- OUTSIDE lines should always have equal length as long as arcs are same distance
- Tangent should be GeomType.CIRCLE
- Arcs must be coplanar
- Cannot make tangent for radius under certain size
- Cannot make tangent for concentric arcs
"""
# Test line properties in algebra mode
start_r = 2
end_r = 5
separation = 10
start_point = (0, 0)
end_point = (0, separation)
start_arc = CenterArc(start_point, start_r, 0, 360)
end_arc = CenterArc(end_point, end_r, 0, 360)
radius = 15
lines = []
for keep in [Keep.INSIDE, Keep.OUTSIDE]:
for side in [Side.LEFT, Side.RIGHT]:
l1 = ArcArcTangentArc(start_arc, end_arc, radius, side=side, keep=keep)
self.assertEqual(l1.geom_type, GeomType.CIRCLE)
self.assertAlmostEqual(l1.radius, radius)
# Check coincidence, tangency with each arc
_, p1, p2 = start_arc.distance_to_with_closest_points(l1)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
start_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
_, p1, p2 = end_arc.distance_to_with_closest_points(l1)
self.assertTupleAlmostEquals(tuple(p1), tuple(p2), 5)
self.assertAlmostEqual(
end_arc.tangent_at(p1).cross(l1.tangent_at(p2)).length, 0, 5
)
lines.append(l1)
self.assertAlmostEqual(lines[-2].length, lines[-1].length, 5)
# Test in off-axis builder mode at multiple angles and compare to prev result
workplane = Plane.XY.rotated((45, 45, 45))
with BuildLine(workplane):
end_center = workplane.from_local_coords(end_point)
point_arc = CenterArc(end_center, separation, 0, 360)
end_arc = CenterArc(end_center, end_r, 0, 360)
points = [1, 2, 3, 5, 7, 11, 13]
for point in points:
start_center = point_arc @ (point / 16)
start_arc = CenterArc(point_arc @ (point / 16), start_r, 0, 360)
mid_vector = end_center - start_center
mid_perp = mid_vector.cross(workplane.z_dir)
for keep in [Keep.INSIDE, Keep.OUTSIDE]:
for side in [Side.LEFT, Side.RIGHT]:
l2 = ArcArcTangentArc(
start_arc, end_arc, radius, side=side, keep=keep
)
# Check length against algebraic length
if keep == Keep.INSIDE:
self.assertAlmostEqual(lines[0].length, l2.length, 5)
side_sign = 1
elif keep == Keep.OUTSIDE:
self.assertAlmostEqual(lines[2].length, l2.length, 5)
side_sign = -1
# Check side of midline
_, _, p2 = start_arc.distance_to_with_closest_points(l2)
coincident_dir = mid_perp.dot(p2 - start_center)
center_dir = mid_perp.dot(l2.arc_center - start_center)
if side == Side.LEFT:
self.assertLess(side_sign * coincident_dir, 0)
self.assertLess(center_dir, 0)
elif side == Side.RIGHT:
self.assertGreater(side_sign * coincident_dir, 0)
self.assertGreater(center_dir, 0)
## Error Handling
start_arc = CenterArc(start_point, start_r, 0, 360)
end_arc = CenterArc(end_point, end_r, 0, 360)
# GeomType
bad_type = Line((0, 0), (0, 10))
with self.assertRaises(ValueError):
ArcArcTangentArc(start_arc, bad_type, radius)
with self.assertRaises(ValueError):
ArcArcTangentArc(bad_type, end_arc, radius)
# Coplanar
with self.assertRaises(ValueError):
ArcArcTangentArc(CenterArc((0, 0, 1), 5, 0, 360), end_arc, radius)
# Radius size
with self.assertRaises(ValueError):
r = (separation - (start_r + end_r)) / 2 - 1
ArcArcTangentArc(CenterArc((0, 0, 1), 5, 0, 360), end_arc, r)
def test_line_with_list(self): def test_line_with_list(self):
"""Test line with a list of points""" """Test line with a list of points"""
l = Line([(0, 0), (10, 0)]) l = Line([(0, 0), (10, 0)])