JanetRossini.github.io

Lua, LSL, Blender, Python in Second Life


Project maintained by JanetRossini

Back to Linkages

Aug 21, 2025 • [designlinkagesluatesting]


After our brief trip into AI-suggested code for finding points, let’s get back to the main thread here, defining linkages and animating them. A bit of progress.

We’ll start with a bit of “design thinking”, reflecting on what we’ve done so far, what we’ve learned, what we speculate about. We’ll get a sense of where we want to go. No big leaps: just an awareness that we’re trying to lean a bit “over that way” a bit more, rather than “over there” where we were originally heading. In my style of work on these things, it is a voyage of discovery, not one of prediction. I build up a sense of what I want to accomplish and a sense of how to do it, and refine both as we move along.

Two matters are starting to appear interesting to me: constraints, and expressing the linkage given a model of some kind, in SL or on paper.

Constraints
So far, we’ve defined three classes, DriveWheel, CouplingRod, and ConnectingRod. Each of these understands how to move, in code directly present in the class. Not too surprising. But over the past few days, we’ve been working with generic code for finding points satisfying constraints: fixed distance to a line, or to a circle.

It seems to me, very fuzzily so far, that a ConnectingRod, say, might be expressed by two “constraints”: its one end is constrained to be exactly the position of the DriveWheel crank; its other end satisfies the constraint that it is on a provided line, and a fixed distance from the first end.

It’s too early to say, but it seems likely that there could be many different objects with one or both of those two constraints, or other constraints such as “horizontal” or “distance D from this circle”. So perhaps there will be some kind of classes, or pluggable functions, that are sub-components of things like ConnectingRod.

We’ll see. The code will tell us.

Expressing the Linkage
As I’m doing it now, I sketch a bit of linkage on paper or iPad, assign some numbers to the coordinates, and type in the values. In SL, we build things out of smaller objects, and those objects can easily tell us their central “root” position, and their rotation around that point. It is more difficult, and error-prone in SL, to work out the end points of a shape. It’s not horrible, we’ll typically know overall lengths and such, but it can get tricky and when we put wrong numbers into the animation grinder it tends to make a mess of things, and, because we usually forget to save the starting position, it is hard to put right.

So as we go, I’ll try to think about defining my objects in terms of their root coordinates and rotation (in degrees). It’s too soon to say just how the definitions should be done: I just want to be a bit more aware of it.

Today’s Work?

I think what I’d like to do today is to revamp the ConnectingRod to use the new more general function that I adapted from the “AI” suggestion. First we’ll make it work for our existing tests, and then try some tests that aren’t just horizontal lines.

A case could be made for not doing this, at least not this way. Let’s look at the code.

function ConnectingRod:calculate()
    base_pos = self._parent:position()
    local rotated = self._parent:adjusted_offset(self._offset)
    local wheel_end = base_pos + rotated
    self._position = self:center_position(base_pos, wheel_end)
end

function ConnectingRod:center_position(base_pos, wheel_end)
    return wheel_end + self:center_delta(base_pos, wheel_end)
end

function ConnectingRod:center_delta(base_pos, wheel_end)
    local delta_y = base_pos.y - wheel_end.y
    local delta_x = - math.sqrt(self._length*self._length - delta_y*delta_y)
    return vector(delta_x, delta_y) / 2
end

The meat of this object is in center_delta. (Reading this code with fresh eyes makes me want to improve it.)

center_delta is made up of two parts. The first part computes the offset (delta-x, delta_y) of the far end of the rod, and then we return half of that, presumably the center of the piece, its root position. (That assumption could be false. Steady on, it’s early days, we’re still trying to make this stuff work at all.)

This version of the ConnectingRod assumes that the piston rod is level with the x axis. This is not always true: some locomotives have angled pistons. I believe they do it to make my job harder.

The delta-y, the distance that the far end is above or below the wheel end is the altitude of a right triangle with the hypotenuse reaching from the wheel end to the line level with the drive wheel center. And we calculate delta_x with the everyday right triangle formula. Then we take half of it.

This is not general, but it is pretty fast. Now let’s look at the general solution as we now have it. It’s only in a test just now, not packaged:

_:test("another point on line at distance approach", function()
    local p0 = vector(3,2)
    local p1 = vector(7,3)
    local p2 = vector(11,8)
    local distance = 6.5
    local unit_direction = (p2 - p1):normalize()
    local distance_to_closest = (p0 - p1):dot(unit_direction)
    local p_closest = p1 + unit_direction*distance_to_closest
    local closest_distance = p0:dist(p_closest)
    local offset_distance = math.sqrt(distance*distance - closest_distance*closest_distance)
    local c1 = p_closest + unit_direction*offset_distance
    local c2 = p_closest - unit_direction*offset_distance
    _:expect(p0:dist(c1)).is(6.5, 0.01)
    _:expect(p0:dist(c2)).is(6.5, 0.01)
end)

It is possible that I explained this in a prior article. For now I propose to just accept it.

One issue with this is that it computes both possible results, and one of them is way wrong for our purposes. To use this, we’ll have to come up with a scheme for selecting the correct one. Another issue is that embedded in here there are at least three calls to math.sqrt, in normalize, in dist, and a direct call. Our current scheme just does one sqrt, which I would bet takes less time than three. Our animations are very busy, moving quite a few objects around every tenth of a second, and speed is important.

That might argue for retaining the current scheme for the common case of horizontal piston movement, using the more general form for a separate class that handles the general case. Another possibility is that we might be able to get our result by some kind of rotation trick. It’s not a direct rotation of the result, because if the piston is above or below the center line, the distance to the piston rod is different from what it would be if it were centered.

I’d have to do some math to figure that out.

For now, let’s assume that we’ll create a separate class for the general case, because I want to compare the two schemes anyway.

I’ll clone my connecting rod tests, I guess, and see what I get.

function _:featureGeneralConnectingRod()
    _:describe("general connecting rod", function()

        local wheel
        local con_rod
        _:setup(function()
            wheel = DriveWheel{x=10, y=2}
            con_rod = GeneralConnectingRod{parent=wheel, length=6, radius=1}
        end)

This won’t really do. We need two points to define the line segment of the piston rod. I’ll change the test to use the parameters of my test shown above.

And so on. This of course fails for want of a class.

After Some Delay …

This isn’t working out. I should stop, take a break. But no, not yet. I’m going to try once more, even though I really know I should back away slowly.

Ha! Found it! A simple error. I have this test, matching my old ones, using the new class:

function _:featureGeneralConnectingRod()
    _:describe("general connecting rod", function()

        local wheel
        local con_rod
        _:setup(function()
            wheel = DriveWheel{x=10, y=2}
            con_rod = GeneralConnectingRod{parent=wheel, length=6, radius=1, p1=vector(7,2), p2=vector(11,2)}
        end)

        _:test("initial position", function()
            _:expect(con_rod:position()).is(vector(8, 2))
        end)

        _:test("180", function() 
            wheel:move(2*math.pi)
            con_rod:calculate()
            _:expect(con_rod:position()).is(vector(6, 2))
        end)

        _:test("90", function() 
            wheel:move(2*math.pi/2)
            con_rod:calculate()
            local dx = math.sqrt(35)
            local ex = 10-dx
            local exp = (10+ex)/2
            _:expect(con_rod:position()).is(vector(exp, 1.5))
        end)

        _:test("270", function() 
            wheel:move(3*2*math.pi/2)
            con_rod:calculate()
            local dx = math.sqrt(35)
            local ex = 10-dx
            local exp = (10+ex)/2
            _:expect(con_rod:position()).is(vector(exp, 2.5))
        end)
    end)
end

And the new code:

GeneralConnectingRod = class()
function GeneralConnectingRod:init(parms)
    self._parent = parms.parent or error('expected parent')
    self._length = parms.length  or error('expected length')
    self._offset = vector(parms.radius or error('expected radius'), 0)
    self._p1 = parms.p1 or error('expected p1')
    self._p2 = parms.p2 or error('expected p2')
    self._position = vector(0,0)
    self:calculate()
end

function GeneralConnectingRod:calculate()
    local p1 = self._p1
    local p2 = self._p2
    local base_pos = self._parent:position()
    local rotated = self._parent:adjusted_offset(self._offset)
    local wheel_end = base_pos + rotated
    local p0 = wheel_end
    local distance = self._length
    local unit_direction = (p2 - p1):normalize()
    local distance_to_closest = (p0 - p1):dot(unit_direction)
    local p_closest = p1 + unit_direction*distance_to_closest
    local closest_distance = p0:dist(p_closest)
    local offset_distance = math.sqrt(distance*distance - closest_distance*closest_distance)
    local point_1 = p_closest + unit_direction*offset_distance
    local point_2 = p_closest - unit_direction*offset_distance
    local other_end = point_2 -- guessing
    self._position = (other_end + wheel_end) / 2
end

function GeneralConnectingRod:position()
    return self._position
end

It still doesn’t know how to decide which point to use, but I have a theory about that. And we had to provide two points to define the line: we want to provide one point and an angle, I think.

But it is working, for this case, and because I think the basic function works for all cases, this is progress.

The code needs cleaning up: I just hammered it to match my needs and have not yet even tried to improve it.

But I think, probability around 0.75, that we have a GeneralConnectingRod working.

I’ve also come to realize that I need to move some of these larger functions off into separate files. The linkages file is just shy of 700 lines, which is way too long to deal with. I really need a better IDE than SublimeText for these large problems, but, well, there isn’t one that I know of. Anyway that tidying will be restful when I get to it.

Now I will take that break, feeling better with a bit of actual progress.