Bell Crank Target
As much as I’d like to futz with the drawing code, I think it’s time to continue work on the ArcRod. I’m goin’ in!
The job of the ArcRod is to connect from a crank at its start end to a component at the finish end that constrains that end to an arc of a circle. Geometrically, the finish position is one of the points where two circles intersect. One of those circles has radius equal to the length of the ArcRod, and center = start, while the other has some given radius and center. Typically the fixture at the finish end is a “bell crank” with a fixed center and the ability to rotate. More complex linkages are possible, but we’ll always have the ArcRod’s start and length, and the center and radius of the target.
I worked out, with some help from the Internet, a solution where the line between the start and the target center is horizontal, generating these two cards, which still reside right here on my desk:

I believe we have at least one test relating to this. Let’s go see what’s in there.
class TestArcRod:
def test_exists(self):
length = 5
position = vector(5,5)
radius = 1
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
assert arc_rod._length == length
def test_update(self):
length = 5
position = vector(6,0)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
ar_start = vector(None, None)
ar_finish = vector(0,0)
start, finish = arc_rod.update_both(ar_start, ar_finish)
assert finish.distance(start) == pytest.approx(length)
assert position.distance(finish) == pytest.approx(radius)
The first test was just boilerplate to get me started with a test and to drive out the class. The second one actually tests a method update_both, which probably was intended to return both possible answers. Clearly right now it does not do that. Let’s review the ArcRod class:
class ArcRod:
def __init__(self, *, length, target_position, target_radius):
self._length = length
self._target_position = target_position
self._target_radius = target_radius
def update_both(self, _p_start, p_finish):
opposite_side = self._target_radius
adjacent_side_1 = self._length
adjacent_side_2 = p_finish.distance(self._target_position)
angle = opposite_angle(opposite_side, adjacent_side_1, adjacent_side_2)
offset = vector(adjacent_side_1, 0).rotate_xy(angle)
result = p_finish + offset
return p_finish, result
That code calls this function:
def opposite_angle(side_a, side_b, side_c):
a_squared = side_a * side_a
b_squared = side_b * side_b
c_squared = side_c * side_c
b_c = side_b * side_c
cos_angle_opposite_side_a = (b_squared + c_squared - a_squared) / (2 * b_c)
return math.acos(cos_angle_opposite_side_a)
That function uses the “Law of Cosines”. Given three sides of a triangle, a, b, and c, it returns the angle opposite side a, in radians. The offset from start of the point we want is just the length of the ArcRod, rotated by that angle. (And the offset of the other point that we want is the length rotated by the negative of the angle.)
Let’s change the test to expect and check both points, then make that happen.
def test_update(self):
length = 5
position = vector(6,0)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
ar_start = vector(None, None)
ar_finish = vector(0,0)
start, finish_1, finish_2 = arc_rod.update_both(ar_start, ar_finish)
assert finish_1 != finish_2
assert finish_1.distance(start) == pytest.approx(length)
assert position.distance(finish_1) == pytest.approx(radius)
assert finish_2.distance(start) == pytest.approx(length)
assert position.distance(finish_2) == pytest.approx(radius)
With this code, we pass:
def update_both(self, _p_start, p_finish):
opposite_side = self._target_radius
adjacent_side_1 = self._length
adjacent_side_2 = p_finish.distance(self._target_position)
angle = opposite_angle(opposite_side, adjacent_side_1, adjacent_side_2)
offset_1 = vector(adjacent_side_1, 0).rotate_xy(angle)
offset_2 = vector(adjacent_side_1, 0).rotate_xy(-angle)
return p_finish, p_finish + offset_1, p_finish + offset_2
Green. Commit: update_both returns both candidate points.
OK, I’m warmed up, more or less. Now let’s think about what we actually need, and how we might get it.
In our final implementation, the line between our start and the center of the target bell crank will not be horizontal. We will need to know what angle that line is, and to rotate the two offset points by that amount to get the actual points.
Here’s a sketch showing a flat solution rotating up to a 45 degree rotated one. You can see that if we rotate around the source end of the ArcRod, we get the offsets where they need to be. This is working like our ConnectingRod, where we compute a flat solution and then rotate to get the actual.

So … it seems to me … what we really need from our code is the two offsets. Let’s refactor to get those, starting from the update_both shown above. PyCharm Extract Method:
def update_both(self, _p_start, p_finish):
offset_1, offset_2 = self.target_offsets(p_finish)
return p_finish, p_finish + offset_1, p_finish + offset_2
def target_offsets(self, p_finish):
opposite_side = self._target_radius
adjacent_side_1 = self._length
adjacent_side_2 = p_finish.distance(self._target_position)
angle = opposite_angle(opposite_side, adjacent_side_1, adjacent_side_2)
offset_1 = vector(adjacent_side_1, 0).rotate_xy(angle)
offset_2 = vector(adjacent_side_1, 0).rotate_xy(-angle)
return offset_1, offset_2
Green. Commit: break out calculation of offsets.
Let’s work out a test that uses the angle: it seems like the proper thing to do. An easy one would have the angle at 90 degrees, vertical. That should have the same answer as the current one with x and y reversed. Sort of.
As I study the existing test, I notice a naming issue, and another issue:
def test_update(self):
length = 5
position = vector(6,0)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
ar_start = vector(None, None)
ar_finish = vector(0,0)
start, finish_1, finish_2 = arc_rod.update_both(ar_start, ar_finish)
assert finish_1 != finish_2
assert finish_1.distance(start) == pytest.approx(length)
assert position.distance(finish_1) == pytest.approx(radius)
assert finish_2.distance(start) == pytest.approx(length)
assert position.distance(finish_2) == pytest.approx(radius)
The update_both function takes two vector parameters. They are supposed to be the start and finish of the parent object, typically a crank. Here in the test, I called them ar_start and ar_finish suggesting ar = arc rod. And the start is not used. Let’s change signature and rename.
def test_update(self):
length = 5
position = vector(6,0)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
parent_finish = vector(0,0)
start, finish_1, finish_2 = arc_rod.update_both(parent_finish)
assert finish_1 != finish_2
assert finish_1.distance(start) == pytest.approx(length)
assert position.distance(finish_1) == pytest.approx(radius)
assert finish_2.distance(start) == pytest.approx(length)
assert position.distance(finish_2) == pytest.approx(radius)
I also note that we’re passing 0,0 as the parent finish, which will be the origin of our rod. That’s too easy and could lead to errors. We’ll do some more tests about that. I’ve made a note on a card. For now, we’ll do the 90 degree test:
def test_update_90(self):
length = 5
position = vector(0,6)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
parent_finish = vector(0,0)
start, finish_1, finish_2 = arc_rod.update_both(parent_finish)
assert finish_1 != finish_2
assert finish_1.distance(start) == pytest.approx(length)
assert position.distance(finish_1) == pytest.approx(radius)
assert finish_2.distance(start) == pytest.approx(length)
assert position.distance(finish_2) == pytest.approx(radius)
Same as the other one except position is <0,6>, not <6,0>. Test duly fails.
Seems to me that in our newly refactored update_both method, we need to apply the angle. Currently:
def update_both(self, p_finish):
offset_1, offset_2 = self.target_offsets(p_finish)
return p_finish, p_finish + offset_1, p_finish + offset_2
We need the angle of the line from p_finish to our member target_position. Atan2 will be our friend here, I think.
def update_both(self, p_finish):
axis = self._target_position - p_finish
angle = math.atan2(axis.y, axis.x)
offset_1, offset_2 = self.target_offsets(p_finish)
rotated_1 = offset_1.rotate_xy(angle)
rotated_2 = offset_2.rotate_xy(angle)
return p_finish, p_finish + rotated_1, p_finish + rotated_2
I get the vector from parent to target, use atan2 to get its angle. Apply that angle to the two returned offsets and use those results in the answer.
The tests both pass. I am gratified but it almost seems too easy. Let’s try another test, with a non-zero starting point.
def test_update_offset_horizontal(self):
length = 5
position = vector(9,5)
radius = 2
arc_rod = ArcRod(length=length, target_position=position, target_radius=radius)
parent_finish = vector(3,5)
Passes. Let’s commit: all this code is in the test file anyway. Commit: seems to be working, need more tests.
I think I want to try this on the screen. I also think I need a break, and this is a perfect moment for that. Let’s sum up.
Summary
Someone once said “In theory there is no difference between theory and practice, but in practice there is.” Today, it seems, my theory about this capability has been checked in practice and found to be working. When that 90 degree test ran first time, frankly I was a bit surprised. I had figured that I must have made a misstep somewhere along the way. That’s still possible but things are looking quite good.
I’ll believe it when I see it working on the screen. And there is a sub-problem not yet solved: how will we select which of the two solutions is the one that we want? I am guessing we’ll provide a point to be near, or an angle to be near, but it remains to be figured out.
But so far, my math seems to be holding up. I’ll publish this now and add a picture after I draw it.
See you soon!