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'?>
<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 fill="none" stroke="rgb(0,0,0)" stroke-width="0.0225">
<path d="M 6.0,0.0 A 47.000000000000085,47.000000000000085 180.0 0,0 9.615385,18.076923" />
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.00900000018">
<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" />
</g>
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.0225" id="dashed" stroke-dasharray="0.27 0.405">
<path d="M 0.0,20.0 A 5.0,5.0 180.0 1,0 2.5,15.669873" />
<line x1="6.0" y1="0.0" x2="6.0" y2="5.0" />
<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,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="1.0" />
</g>
</g>
</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
.. 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
^^^^^^^^^
@ -210,6 +237,10 @@ Reference
.. autoclass:: Spline
.. autoclass:: TangentArc
.. autoclass:: ThreePointArc
.. autoclass:: ArcArcTangentLine
.. autoclass:: ArcArcTangentArc
.. autoclass:: PointArcTangentLine
.. autoclass:: PointArcTangentArc
2D Objects
----------

View file

@ -176,7 +176,6 @@ svg.write("assets/polyline_example.svg")
with BuildLine(Plane.YZ) as filletpolyline:
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)
visible, _hidden = scene.project_to_viewport((0, 0, 1), (0, 1, 0))
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")
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)
p1 = (6, 0)
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)
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.add_shape(dot.scale(5).moved(Pos(p1)))
svg.add_shape(PolarLine(p1, 1, direction=d1), "dashed")
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_2.line, name="Ex. 2")
# show_object(example_3.line, name="Ex. 3")

View file

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

View file

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

View file

@ -29,16 +29,24 @@ license:
from __future__ import annotations
import copy as copy_module
from collections.abc import Iterable
from math import copysign, cos, radians, sin, sqrt
from scipy.optimize import minimize
from collections.abc import Iterable
import sympy # type: ignore
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.geometry import Axis, Plane, Vector, VectorLike, TOLERANCE
from build123d.topology import Edge, Face, Wire, Curve
from build123d.topology.shape_core import ShapeList
def _add_curve_to_context(curve, mode: Mode):
@ -1048,3 +1056,426 @@ class ThreePointArc(BaseEdgeObject):
arc = Edge.make_three_point_arc(*points_localized)
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.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):
"""Test line with a list of points"""
l = Line([(0, 0), (10, 0)])