Maybe we should constrain points, not wheels and rods. Then wheels and rods would span points …
I drew some pictures of linkages yesterday, and with each point of interest on each one, I wrote notes about the constraints on that point: must be on this line, must be this far from that point, and so on. It began to seem to me that the point, not the component might be the central entity to be dealt with.
I’m not sure that it makes a difference, but I think it might, and it seems that it is simpler to have a bunch of smart points rather than a bunch of components with two or more smart points. Generally speaking, the points have to be computed one at a time anyway. (There could be exceptions to this, but I don’t have any in mind.)
Any component’s position and rotation are surely defined by two points in the plane, so we can plunk down components on points as needed. Or so it seems to me.
This could be a good idea. It could also not be a good idea. But it seems interesting, so let’s find out.
So today, let’s see about some possible new objects:
I think I’ll start in a new lua file. This is quite tentative and the linkage.lua file is getting cluttered anyway.
local class = require('./class')
local _ = require('./expectations')
local vector = require('./vector')
local function tovector(s)
return vector:tovector(s)
end
local svg = require('./svg')
local TWO_PI = 2*math.pi
local DEG_TO_RAD = math.pi/180
local RAD_TO_DEG = 180/math.pi
function _:featureHookup()
_:describe("hookup", function()
_:test("hookup", function()
_:expect(2+2).is(4)
end)
end)
end
_:execute()
I think I’ll save that as base.lua, so I can reuse it.
Here’s the rub: what should I do as my first test? Let’s do a simple one. Suppose we have a point which represents the crank point on a rotating wheel, and we want the center point of the side rod that drives the other wheels. That point is constrained to be a given x distance from the control point. I’ll start by supposing there is an … OffsetPoint we’ll call it.
I kind of went wild with this as a first test:
function _:featureOffsetPoint()
_:describe("point at offset", function()
_:test("zero based", function()
local p = vector(0,0)
local o = vector(1,2)
local pt = OffsetPoint{parent=p, offset=o}
local r = pt:position()
_:expect(r).is(vector(1,2))
end)
end)
end
I think it should be easy. We’ll see. Fails, of course for want of anything named OffsetPoint.
OffsetPoint = class()
function OffsetPoint:init(parms)
self._parent = parms.parent or error("expected parent")
self._offset = parms.offset or error("expected offset")
end
Now it should fail for want of position()
. Yes. Provide it:
function OffsetPoint:position()
return self._parent:position() + self_offset
end
It seemed clear to me that if it is to be points all the way down, we should ask the parent for position()
. That won’t work for a vector. I need a new class. I’ll just call it out right in these tests and then provide it:
local OffsetPoint
function _:featureOffsetPoint()
_:describe("point at offset", function()
_:test("zero based", function()
local p = FixedPoint{position=vector(0,0)}
local o = vector(1,2)
local pt = OffsetPoint{parent=p, offset=o}
local r = pt:position()
_:expect(r).is(vector(1,2))
end)
end)
end
And, quickly:
FixedPoint = class()
function FixedPoint:init(parms)
self._position = parms.position or error("expected position")
end
function FixedPoint:position()
return self._position
end
I kind of expect my test to run now. It does not. I get this:
Feature: point at offset
Error: /Users/ron/Desktop/repo/vector.lua:27: attempt to index nil with 'x'. Test: 'zero based'.
point at offset, 1 Tests: Pass 0, Fail 1, Ignored 0.
What have I done wrong? Oh, missing dot. This is better:
function OffsetPoint:position()
return self._parent:position() + self._offset
end
And my test passes. Let’s reflect.
On the one hand, we have about 20 lines of trivial code. Not too impressive. On the other hand, we have two objects that collaborate to apply a particular constraint (we could call it “offset”) to the second point. We have the beginning of a general scheme for things.
I’m pleased.
I’m also wondering whether we should focus more on the constraint, and less on the point.
What if a constraint was a function? I mean it surely will be a function, i.e. a method in whatever kind of point it is, but what if the focus was more on the function and less on the Point?
I’m not ready to try this yet. Let’s do another kind of point, the one that is a fixed distance from another point, at a given angle.
The big question before me is whether to make this a new feature or embed it in the existing one, perhaps giving it a more general name. The lazy me wants to do the latter. The experienced me things this is likely to be a problem. Nonetheless, I’m going with lazy but alert. I’ll compromise and do a new describe.
_:describe("fixed distance at angle", function()
_:test("0", function()
local parent = FixedPoint{position=vector(5,5)}
local point = DistanceAnglePoint{parent=parent, length=5, angle=0}
local r = point:position()
_:expect(r).is(vector(10,5))
end)
end)
Shall I do fake it till you make it here? I think I will.
DistanceAnglePoint = class()
function DistanceAnglePoint:init(parms)
self._parent = parms.parent or error("expected parent")
self._length = parms.length or error("expected length")
self._angle = parms.angle or error("expected angle")
end
function DistanceAnglePoint:position()
local offset = vector(self._length, 0)
return self._parent:position() + offset
end
That passes. We need a better calculation for the offset. I have code that does that, in the linkages file. But it’s not at all clear:
function MainRod:compute_positions(wheel, tilt_angle, length, radius)
local adjust_angle = tilt_angle*DEG_TO_RAD
local wheel_angle = wheel:angle()*DEG_TO_RAD - adjust_angle
local x = radius*math.cos(wheel_angle)
local y = radius*math.sin(wheel_angle)
-- print("compute", tilt_angle, x, y)
self._start_position = self._parent:position() + vector(x,y):rotate_2d(adjust_angle)
local x_proj_sq = length*length - y*y
local x_proj = math.sqrt(x_proj_sq)
-- print("proj", x_proj)
local result_x = x + x_proj
local unrotated = vector(result_x, 0)
local rotated = unrotated:rotate_2d(adjust_angle)
-- fix below
return rotated + self._parent:position()
end
I should redo this, but let’ see if we can make it work. I only have a few more minutes this morning.
Ah. Bad assumption. It’s not that simple. I was assuming that the angle provided was relative to the fixed point, but in fact it is relative to the center around which the fixed point was rotating. We need a center and an offset.
I’ll fold my tent for now and come back to this later or tomorrow.
And tomorrow it is: it is now Monday!
Kent Beck has said that he loves to design and likes to let his code participate in the design. I take that to mean that we should hold onto design ideas lightly, because often as we try to implement them we’ll encounter difficulties or get ideas that could lead to a better design, and we should remain open to those discoveries.
I have a few discoveries with this new point scheme already:
We’ll keep those in mind and focus on the class I was working on yesterday, then called DistanceAnglePoint. This is intended to be the point at the end of a connecting rod (MainRod) that finds its position along a line extending from the center of a wheel or other crank, a fixed distance from a point which is some radius away from that center, at some angle.
If that’s hard to understand, I’m with you.
I think what I’ll do is continue with DistanceAnglePoint, and if I can make it work, then explore how best to get the information it needs.
Imagine a wheel, with center (wx,wy)
. It has a crank point on it at radius r
. The crank point may not be at the zero angle of the wheel, so it is at a point with polar coordinates (r, lag_angle)
, which gives it some Cartesian coordinates (cx,cy)
. The connecting rod whose end we’re computing has some length d
. And it is intended to connect to the line from wheel center at angle rod_angle
. Finally, the wheel itself may be rotated so that its zero point is now at wheel_angle
. That will move the crank point to r,lag_angle+wheel_angle)
with a corresponding (cx, cy)
That’s a lot isn’t it? I think I’ll write a simpler test and work up to the Point. We’ll test a function that finds the desired end point, given whatever parameters we decide to use. Then we’ll see how to embed that function in a Point object, and work out how that object will find what it needs to know.
First, find out what we need to know, then find out how to get it. New test for this one.
_:describe("point on line finder", function()
local function point_on_line_at_distance(d)
return vector(0,0)
end
_:test("all angles zero", function()
local wheel_pos = vector(0, 0)
local r = 1
local crank_pos = vector(r, 0)
local d = 5
local pt = point_on_line_at_distance(d)
_:expect(crank:dist(pt)).is(d)
end)
end)
That’s not complete, but it is enough to get me started. I’ve embedded the function right inside the describe
, keep your enemies close. This should fail, driving out any typos and finally returning the wrong answer. Fails as intended. Now I”ll write a function inside the dummy one above. That will tell me what parameters I need.
local function point_on_line_at_distance(d)
-- assuming angles all zero
local result_x = distance + radius
return vector(wheel_center.x + result_x, 0)
end
Yes, I could work out the general formula right now and code it up. Maybe even get it right. I can run my tests instantly, so I’d rather creep up on it.
I need some parameters. Define and provide, and fix typo in test:
_:describe("point on line finder", function()
local function point_on_line_at_distance(wheel_center, radius, distance)
-- assuming angles all zero
local result_x = distance + radius
return vector(wheel_center.x + result_x, 0)
end
_:test("all angles zero", function()
local wheel_pos = vector(0, 0)
local r = 1
local crank_pos = vector(r, 0)
local d = 5
local pt = point_on_line_at_distance(wheel_pos, r, d)
_:expect(crank_pos:dist(pt)).is(d)
end)
end)
The test passes. I am not exactly satisfied. One thing I’d like to be able to test is that the result point isn’t just the right distance away, but it is actually on the line in question. I think we can get that information, but I’ll defer it. Instead, and this is a crock, I’m going to print the answers for a while. It prints the vector <6,0,0>
, which is what I had in mind.
Let’s do a test with the line angle still zero but the point at some other angle. We’ll have a total angle of 90, which means that the x distance along the line will be provided by the quadratic formula, which we’ll add into the code where it seems to belong. And we’ll need another bit of info, the angle.
_:test("net angle 90", function()
local wheel_pos = vector(0, 0)
local r = 1
local angle = 90
local crank_pos = vector(0, r)
local d = 5
local pt = point_on_line_at_distance(wheel_pos, r, angle, d)
_:expect(crank_pos:dist(pt)).is(d)
end)
Note that I’m still providing angles in degrees. This is because in use, our object editors will have degrees, so for ease of input, I’m trying to accept degrees and convert them internally. So far I haven’t forgotten a conversion … or maybe I just didn’t notice?
local function point_on_line_at_distance(wheel_center, radius, angle, distance)
local theta = angle*DEG_TO_RAD
local crank_pos = vector(radius,0):rotate_2d(theta)
local y = crank_pos.y
local dx = math.sqrt(distance*distance - y*y)
local result_x = crank_pos.x + dx
local answer = vector(wheel_center.x + result_x, 0)
print(answer)
return answer
end
This runs, with the result of my 90 test showing 4.8989, which all recognize as the square root of 24, and the correct answer.
It’s time for a break. Looking forward, I believe that we can use this code almost as is. If the rod angle is non-zero we can rotate the crank position downward by that angle, solve at angle 0, and rotate back by the rod angle. That’s my plan when we get there.
At that point, we’ll be close to knowing what we need to provide to our Point object so that it can use this convenient function we’re working on now. Of course that function will be enhanced first.
I think this is longer than we needed. I’ll save it and pick up in a new article next time.
Safe paths!